diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 09868a1..f143dc9 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -15,26 +15,45 @@ jobs:
- name: validate gradle wrapper
uses: gradle/actions/wrapper-validation@v4
- name: setup jdk
- uses: actions/setup-java@v4
+ uses: actions/setup-java@v5
with:
- java-version: '21'
+ java-version: '25'
distribution: 'microsoft'
- name: make gradle wrapper executable
run: chmod +x ./gradlew
- name: build
run: ./gradlew build
- - name: capture Fabric artifact
+ - name: capture Fabric 1.21.11 artifact
uses: actions/upload-artifact@v4
with:
- name: VideoPlayer-Fabric
+ name: VideoPlayer-Fabric-1.21.11
path: |
- build/libs/VideoPlayer-*.jar
- !build/libs/*-sources.jar
- !build/libs/*-dev-shadow.jar
- - name: capture Paper artifact
+ fabric-1.21.11/build/libs/VideoPlayer-*.jar
+ !fabric-1.21.11/build/libs/*-sources.jar
+ !fabric-1.21.11/build/libs/*-dev-shadow.jar
+ if-no-files-found: error
+ - name: capture Fabric 26.2 artifact
uses: actions/upload-artifact@v4
with:
- name: VideoPlayer-Paper
+ name: VideoPlayer-Fabric-26.2
+ path: |
+ fabric-26.2/build/libs/VideoPlayer-*-26.2.jar
+ !fabric-26.2/build/libs/*-plain.jar
+ !fabric-26.2/build/libs/*-sources.jar
+ if-no-files-found: error
+ - name: capture Paper 1.21.11 artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: VideoPlayer-Paper-1.21.11
path: |
paper-plugin/build/libs/VideoPlayer-Paper-*.jar
!paper-plugin/build/libs/*-plain.jar
+ if-no-files-found: error
+ - name: capture Paper 26.2 artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: VideoPlayer-Paper-26.2
+ path: |
+ paper-plugin-26.2/build/libs/VideoPlayer-Paper-*-26.2.jar
+ !paper-plugin-26.2/build/libs/*-plain.jar
+ if-no-files-found: error
diff --git a/FOLIA_COMPATIBILITY_AUDIT.md b/FOLIA_COMPATIBILITY_AUDIT.md
new file mode 100644
index 0000000..1dbce37
--- /dev/null
+++ b/FOLIA_COMPATIBILITY_AUDIT.md
@@ -0,0 +1,70 @@
+# Folia compatibility audit
+
+## Targets and limits
+
+This audit covers the server plugin compiled for Luminol/Folia 1.21.11 and Canvas/Folia 26.2. It is a static source review backed by unit tests and compilation against Paper API 1.21.11 and Paper API 26.2. It is not a runtime test on a real Luminol, Canvas, Paper, or Folia server. Canvas-specific behavior that differs from the Paper/Folia API used for compilation remains `uncertain` until deployment testing or the matching Canvas core source confirms it.
+
+`folia-supported: true` only allows the plugin to load. It is not treated as proof of thread safety or functional completeness.
+
+## Pre-modification findings
+
+| Classification | Entry point and call chain | Current context and real owner | Boundary values | Termination and cleanup | Rules |
+|---|---|---|---|---|---|
+| `must_fix` | Client screen draw -> `ScreenRenderer.textureIdentifier` -> `TextureManager.register` | Minecraft client/render thread; the video backend owns the raw GL texture | Raw GL integer ID crossed into a Minecraft texture wrapper | The old raw-ID cache had no release path and survived screen cleanup, disconnect, resource reload, and client stop | 2, 8 |
+| `must_fix` | `VideoQuad.cleanup` / `MpvVideoBackend.cleanupTexture` / `AbstractCameraPlayer.cleanup` -> GL texture deletion | Minecraft render context, MPV shared context, or camera framebuffer owner | Raw GL integer ID | The backend deleted the GL object, but the Minecraft wrapper and cached render layer remained registered | 8 |
+| `suggested_fix` | Resource reload -> texture manager reload -> next screen render | Minecraft resource apply/client context | Resource identifiers and immutable render snapshots | No explicit external-texture registry invalidation existed | 8 |
+| `defer` | `VideoPlayerPaperPlugin.onEnable` -> `FoliaScheduler.initialize` -> one-time `RegionizedServer` detection | Plugin enable/global lifecycle context | Cached boolean only | Scheduler owner is cleared in `onDisable`; the detected server model remains process-stable across plugin reloads | 1, 3 |
+| `defer` | Plugin message -> cloned byte array -> `DataHolder.runStateForPlayer` -> entity scheduler -> `ServerPacketHandler.handle` | Plugin messaging callback hands off to the Player entity owner | Cloned packet bytes, receive timestamp | ByteBuf is released in `finally`; state tasks are cancelled and maps cleared on disable | 1, 6, 7, 8 |
+| `defer` | Player join -> entity scheduler -> fixed-rate `DataHolder.updatePlayer` | Player entity scheduler owns Player, World, and Location reads | UUID and immutable `PlayerPosition` snapshot enter the locked state tables | Quit, entity retirement, disable, and epoch mismatch cancel the task and remove references | 2, 6, 7, 8 |
+| `defer` | World discovery in entity context -> immutable `WorldDescriptor` -> async read -> global locked apply | World access occurs in the owner context; file I/O occurs on the async scheduler; mutation returns through the global state executor | Dimension string and Path snapshot, then parsed configuration data | Request IDs and lifecycle epochs reject stale completion; queues are flushed or cancelled on disable | 2, 6, 8 |
+| `defer` | State mutation -> lazy serialized snapshot -> `WorldSaveQueue` -> async file writer | Locked global state captures the snapshot; async tasks own file I/O | Strings, paths, generation numbers, and serialized snapshots only | Per-world slots are bounded by active dimensions, coalesced, retried, flushed with a timeout, and cancelled on disable | 2, 6, 8 |
+| `defer` | Residence flag/lifecycle event -> global delayed refresh -> per-player entity refresh | Plugin lifecycle/global context enumerates players; each permission read is handed to its Player entity owner | UUID/player task target and immutable permission contexts | Listeners and task handles are cancelled and unregistered in bridge shutdown | 1, 2, 6, 7, 8 |
+
+## Implemented resource lifecycle
+
+The client now assigns every active raw GL texture a process-local generation. An active raw ID reuses its registration, while release followed by OpenGL ID reuse receives a new identifier such as `videoplayer:external_texture/7/2`. The registry stores integers and generation values only; it does not retain Minecraft, Player, Entity, World, Inventory, backend, or GL wrapper objects.
+
+The following owner cleanup paths release the external registration before deleting or rebuilding their textures:
+
+- `VideoQuad.cleanup`
+- `MpvVideoBackend.cleanup` and `MpvVideoBackend.cleanupTexture`
+- `AbstractCameraPlayer.cleanup`
+- `AbstractCameraPlayer.updateTexture` before framebuffer resize
+
+Disconnect, protocol reset, client stop, and client resource reload clear all remaining registrations. The 1.21.11 renderer also clears related `RenderType` entries. The 26.2 renderer clears its immutable `FrameRenderSnapshot` so a submission cannot retain the previous generation.
+
+## Post-modification three-pass review
+
+### 1. Functionality and scope
+
+- Plugin name, mod ID, main classes, public commands, permissions, configuration keys, packet channel, internal version, wire revision, and unrelated playback behavior remain unchanged.
+- `-26.2` is a distribution filename suffix only. It is not added to the internal version or handshake token.
+- The resource change is limited to external texture registration, release, reload, and the directly owned GL/framebuffer cleanup chain.
+
+### 2. Folia and performance
+
+- `FoliaScheduler` is initialized once early in `onEnable`, caches Folia detection, and uses official Paper/Folia scheduler APIs after detection.
+- Business code contains no direct legacy `BukkitScheduler` scheduling entry. Legacy scheduler calls exist only inside the wrapper's non-Folia path.
+- Entity and region wrapper delays are clamped to at least one tick.
+- No synchronous `teleport` call exists. The plugin currently has no teleport operation requiring `teleportAsync`.
+- No `Bukkit.isPrimaryThread()` check is used as a Folia ownership test.
+- Player, World, and Location reads occur in the Player entity owner context. Cross-context state uses UUIDs, strings, paths, byte arrays, configuration objects, or immutable position/permission snapshots.
+- File and network operations run on async workers. The bounded shutdown flush occurs only during plugin disable and does not hold `DataHolder.LOCK` while waiting.
+- No region callback uses `Future.get`, `CompletableFuture.join`, a blocking cross-region wait, or a synchronous database/network/file operation.
+
+### 3. Lifecycle and resources
+
+- Player tracking, reload handshake, world save debounce/retry, Residence retry/cache refresh, native runtime, and yt-dlp task handles have explicit cancellation paths.
+- `DataHolder` clears Player UUID/name state, world state, handshake state, persistence state, and task tables on disable.
+- `ClientVersionTracker` uses concurrent collections and cancels per-player timeout tasks during shutdown or session replacement.
+- `DataHolder` maps and sets remain under the single `DataHolder.LOCK`; this preserves atomic mutations spanning multiple tables and avoids concurrent iteration. They are not replaced independently with concurrent collections.
+- Provider concurrency is bounded by `VideoProviders.RESOLUTION_LIMIT`. World save slots are coalesced per active dimension. Client external texture registrations now have owner release and global cleanup paths.
+- Plugin and Residence listeners are unregistered on disable. Native and scheduler executors are stopped, and lifecycle epochs reject stale callbacks after reload.
+
+## Unavailable Folia events
+
+No listener for `PlayerRespawnEvent`, `PlayerTeleportEvent`, `PlayerChangedWorldEvent`, `WorldLoadEvent`, or `WorldUnloadEvent` exists in the Paper plugin source for either target. Therefore, there is no class/listener to list as using one of these unavailable events. This result is specific to the reviewed Luminol/Folia 1.21.11 and Canvas/Folia 26.2 targets and must be rechecked after a server API upgrade.
+
+## Runtime verification still required
+
+Deployment verification should cover connection and handshake, screen creation and deletion, MPV/VLC playback, camera and 360 rendering, resource reload, disconnect/reconnect, plugin disable/reload, world changes discovered through player tracking, and concurrent players in separate Folia regions. Any conclusion that conflicts with the exact target server core is superseded by that core and should be recorded as a version-specific difference.
diff --git a/README.md b/README.md
index 4f4ea79..c720f32 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
-
+
diff --git a/build.gradle b/build.gradle
index fc88c26..d3bcffd 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,208 +1,259 @@
+import groovy.json.JsonSlurper
+
+import java.nio.charset.StandardCharsets
+import java.util.jar.JarFile
+
plugins {
- id 'fabric-loom' version "${loom_version}"
- id 'com.gradleup.shadow' version '9.2.2'
- id 'maven-publish'
+ id 'base'
+ id 'fabric-loom' version "${loom_version}" apply false
+ id 'net.fabricmc.fabric-loom' version "${loom_version}" apply false
+ id 'com.gradleup.shadow' version '9.2.2' apply false
}
version = project.mod_version
group = project.maven_group
-base {
- archivesName = project.archives_base_name
-}
-
-repositories {
- mavenCentral()
- // Add repositories to retrieve artifacts from in here.
- // You should only use this when depending on other mods because
- // Loom adds the essential maven repositories to download Minecraft and libraries from automatically.
- // See https://docs.gradle.org/current/userguide/declaring_repositories.html
- // for more information about repositories.
- exclusiveContent {
- forRepository {
- maven {
- name = "Modrinth"
- url = "https://api.modrinth.com/maven"
- }
- }
- filter {
- includeGroup "maven.modrinth"
- }
- }
+allprojects {
+ version = rootProject.mod_version
+ group = rootProject.maven_group
}
subprojects {
repositories {
mavenCentral()
maven {
- name = "Fabric"
- url = "https://maven.fabricmc.net/"
+ name = 'Fabric'
+ url = 'https://maven.fabricmc.net/'
}
}
}
-configurations {
- shadowBundle
+tasks.named('build') {
+ dependsOn ':fabric-1.21.11:build'
+ dependsOn ':fabric-26.2:build'
+ dependsOn ':paper-plugin:build'
+ dependsOn ':paper-plugin-26.2:build'
+ dependsOn ':mcng-core:build'
+ dependsOn ':mcng-fabric-client:build'
+ dependsOn ':mcng-fabric-client-26.2:build'
}
-loom {
- splitEnvironmentSourceSets()
-
- mods {
- "videoplayer" {
- sourceSet sourceSets.main
- sourceSet sourceSets.client
- }
- }
+def releaseVersion = project.version.toString()
+def releaseDirectory = layout.buildDirectory.dir('release')
+def releaseArtifacts = [
+ [
+ fileName: "VideoPlayer-${releaseVersion}.jar",
+ source: file("fabric-1.21.11/build/libs/VideoPlayer-${releaseVersion}.jar"),
+ type: 'fabric',
+ minecraft: '~1.21.11',
+ javaDependency: '>=21',
+ classEntry: 'com/github/squi2rel/vp/VideoPlayerMain.class',
+ classMajor: 65,
+ requiredEntries: [
+ 'fabric.mod.json',
+ 'videoplayer.client.mixins.json',
+ 'com/github/squi2rel/vp/network/VideoProtocol.class',
+ 'com/github/squi2rel/vp/render/ExternalTextureRegistry.class',
+ 'META-INF/jars/core-3.5.3.jar',
+ 'META-INF/jars/dec-0.1.2.jar'
+ ]
+ ],
+ [
+ fileName: "VideoPlayer-Paper-${releaseVersion}.jar",
+ source: file("paper-plugin/build/libs/VideoPlayer-Paper-${releaseVersion}.jar"),
+ type: 'paper',
+ apiVersion: '1.21',
+ classEntry: 'com/github/squi2rel/vp/VideoPlayerPaperPlugin.class',
+ classMajor: 65,
+ requiredEntries: [
+ 'plugin.yml',
+ 'com/github/squi2rel/vp/VideoPlayerPaperPlugin.class',
+ 'com/github/squi2rel/vp/FoliaScheduler.class',
+ 'com/github/squi2rel/vp/network/VideoProtocol.class'
+ ]
+ ],
+ [
+ fileName: "VideoPlayer-${releaseVersion}-26.2.jar",
+ source: file("fabric-26.2/build/libs/VideoPlayer-${releaseVersion}-26.2.jar"),
+ type: 'fabric',
+ minecraft: '~26.2',
+ javaDependency: '>=25',
+ classEntry: 'com/github/squi2rel/vp/VideoPlayerMain.class',
+ classMajor: 69,
+ requiredEntries: [
+ 'fabric.mod.json',
+ 'videoplayer.client.mixins.json',
+ 'com/github/squi2rel/vp/network/VideoProtocol.class',
+ 'com/github/squi2rel/vp/render/ExternalTextureRegistry.class',
+ 'com/github/squi2rel/vp/render/FrameRenderSnapshot.class',
+ 'com/github/squi2rel/vp/render/WorldRenderBatch.class',
+ 'com/github/squi2rel/vp/mixin/client/GameRendererTargetAccessor.class',
+ 'com/github/squi2rel/vp/mixin/client/GlDeviceAccessor.class',
+ 'org/brotli/dec/BrotliInputStream.class',
+ 'com/google/zxing/qrcode/QRCodeWriter.class'
+ ]
+ ],
+ [
+ fileName: "VideoPlayer-Paper-${releaseVersion}-26.2.jar",
+ source: file("paper-plugin-26.2/build/libs/VideoPlayer-Paper-${releaseVersion}-26.2.jar"),
+ type: 'paper',
+ apiVersion: '26.2',
+ classEntry: 'com/github/squi2rel/vp/VideoPlayerPaperPlugin.class',
+ classMajor: 69,
+ requiredEntries: [
+ 'plugin.yml',
+ 'com/github/squi2rel/vp/VideoPlayerPaperPlugin.class',
+ 'com/github/squi2rel/vp/FoliaScheduler.class',
+ 'com/github/squi2rel/vp/network/VideoProtocol.class'
+ ]
+ ]
+]
-}
-
-if (findProject(":mcng-core") == null) {
- sourceSets.client.java {
- exclude "com/github/squi2rel/vp/filtergraph/MpvFilterGraphCompiler.java"
- exclude "com/github/squi2rel/vp/filtergraph/MpvFilterGraphManager.java"
- exclude "com/github/squi2rel/vp/filtergraph/MpvFilterGraphNodes.java"
- exclude "com/github/squi2rel/vp/filtergraph/MpvFilterGraphStore.java"
- exclude "com/github/squi2rel/vp/filtergraph/MpvFilterGraphTypes.java"
- exclude "com/github/squi2rel/vp/creation/MpvFilterGraphScreen.java"
- }
-}
-
-dependencies {
- // To change the versions see the gradle.properties file
- minecraft "com.mojang:minecraft:${project.minecraft_version}"
- mappings "net.fabricmc:yarn:${project.yarn_mappings}:v2"
- modImplementation "net.fabricmc:fabric-loader:${project.loader_version}"
-
- // Fabric API. This is technically optional, but you probably want it anyway.
- modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_version}"
-
- implementation "net.java.dev.jna:jna:5.17.0"
- implementation "org.brotli:dec:0.1.2"
- include "org.brotli:dec:0.1.2"
- implementation "com.google.zxing:core:3.5.3"
- include "com.google.zxing:core:3.5.3"
-
- if (findProject(":mcng-core") != null) {
- implementation project(":mcng-core")
- implementation project(path: ":mcng-fabric-client", configuration: "namedElements")
- shadowBundle project(":mcng-core")
- shadowBundle project(path: ":mcng-fabric-client", configuration: "namedElements")
- }
-
- modCompileOnly "maven.modrinth:modmenu:${project.modmenu_version}"
- modApi "maven.modrinth:vivecraft:1.21.11-1.3.9-fabric"
- runtimeOnly "com.electronwill.night-config:core:3.6.6"
- runtimeOnly "com.electronwill.night-config:toml:3.6.6"
- testImplementation platform("org.junit:junit-bom:5.11.4")
- testImplementation "org.junit.jupiter:junit-jupiter"
- testRuntimeOnly "org.junit.platform:junit-platform-launcher"
-}
-
-test {
- useJUnitPlatform()
-}
-
-def bundledAndroidVlcUrl = 'https://github.com/squi2rel/VideoPlayer-Library/releases/download/runtime-20260712-064900/libvlc-android-arm64-v8a.zip'
-def bundledAndroidVlcSha256 = 'dbae70c264a9d86cd8d7fbd7ca35388cbe973de03636c08f0ac8d7cdb493f9ec'
-def bundledAndroidVlcZip = layout.buildDirectory.file('bundled-native/libvlc-android-arm64-v8a.zip')
-
-tasks.register('downloadBundledAndroidVlc') {
- inputs.property 'url', bundledAndroidVlcUrl
- inputs.property 'sha256', bundledAndroidVlcSha256
- outputs.file bundledAndroidVlcZip
+tasks.register('assembleRelease') {
+ group = 'build'
+ description = 'Builds and copies the four supported release archives.'
+ dependsOn ':fabric-1.21.11:build'
+ dependsOn ':fabric-26.2:build'
+ dependsOn ':paper-plugin:build'
+ dependsOn ':paper-plugin-26.2:build'
+ inputs.files(releaseArtifacts.collect { it.source })
+ outputs.dir(releaseDirectory)
+ outputs.upToDateWhen { false }
doLast {
- def target = bundledAndroidVlcZip.get().asFile
- target.parentFile.mkdirs()
- if (target.isFile()) {
- def digest = java.security.MessageDigest.getInstance('SHA-256').digest(target.bytes).encodeHex().toString()
- if (digest == bundledAndroidVlcSha256) return
- target.delete()
+ File target = releaseDirectory.get().asFile.canonicalFile
+ if (!target.toPath().startsWith(rootProject.projectDir.canonicalFile.toPath())) {
+ throw new GradleException("Release directory is outside the project: ${target}")
}
- def temporary = new File(target.parentFile, target.name + '.tmp')
- temporary.delete()
- new URI(bundledAndroidVlcUrl).toURL().withInputStream { input ->
- temporary.withOutputStream { output -> output << input }
+ delete target
+ if (!target.mkdirs() && !target.isDirectory()) {
+ throw new GradleException("Cannot create release directory: ${target}")
}
- def digest = java.security.MessageDigest.getInstance('SHA-256').digest(temporary.bytes).encodeHex().toString()
- if (digest != bundledAndroidVlcSha256) {
- temporary.delete()
- throw new GradleException("Bundled Android VLC SHA-256 mismatch: ${digest}")
+ releaseArtifacts.each { artifact ->
+ File source = artifact.source
+ if (!source.isFile()) throw new GradleException("Missing release input: ${source}")
+ copy {
+ from source
+ into target
+ rename { artifact.fileName }
+ }
}
- if (!temporary.renameTo(target)) throw new GradleException('Failed to store bundled Android VLC package')
}
}
-processResources {
- dependsOn tasks.named('downloadBundledAndroidVlc')
- inputs.property "version", project.version
- from(bundledAndroidVlcZip) {
- into 'assets/videoplayer/native/vlc'
- rename { 'android_arm64-v8a.zip' }
- }
-
- filesMatching("fabric.mod.json") {
- expand "version": inputs.properties.version
- }
-}
-
-tasks.withType(JavaCompile).configureEach {
- it.options.release = 21
-}
-
-java {
- // Loom will automatically attach sourcesJar to a RemapSourcesJar task and to the "build" task
- // if it is present.
- // If you remove this line, sources will not be generated.
- withSourcesJar()
-
- sourceCompatibility = JavaVersion.VERSION_21
- targetCompatibility = JavaVersion.VERSION_21
-}
+tasks.register('verifyRelease') {
+ group = 'verification'
+ description = 'Verifies release filenames, descriptors, bytecode targets, dependencies, and protocol token.'
+ dependsOn tasks.named('assembleRelease')
+ outputs.upToDateWhen { false }
+ doLast {
+ File target = releaseDirectory.get().asFile
+ List expectedNames = releaseArtifacts.collect { it.fileName.toString() }.sort()
+ List children = target.listFiles() == null ? [] : target.listFiles().toList()
+ if (children.any { !it.isFile() }) {
+ throw new GradleException("Release directory contains a non-file entry: ${children.find { !it.isFile() }}")
+ }
+ List actualNames = children.collect { it.name }.sort()
+ if (actualNames != expectedNames) {
+ throw new GradleException("Release directory must contain exactly ${expectedNames}; found ${actualNames}")
+ }
-jar {
- inputs.property "archivesName", project.base.archivesName
+ def readEntry = { JarFile jar, String path ->
+ def entry = jar.getJarEntry(path)
+ if (entry == null) throw new GradleException("${jar.name} is missing ${path}")
+ jar.getInputStream(entry).withCloseable { input -> input.readAllBytes() }
+ }
+ def yamlValue = { String text, String key ->
+ String prefix = key + ':'
+ String line = text.readLines().find { it.startsWith(prefix) }
+ if (line == null) throw new GradleException("plugin.yml is missing ${key}")
+ String value = line.substring(prefix.length()).trim()
+ if (value.length() >= 2 && ((value.startsWith("'") && value.endsWith("'"))
+ || (value.startsWith('"') && value.endsWith('"')))) {
+ value = value.substring(1, value.length() - 1)
+ }
+ value
+ }
+ List forbiddenBuildValues = [
+ 'GRADLE_OPTS',
+ 'org.gradle.jvmargs',
+ 'http.proxyHost',
+ 'https.proxyHost',
+ 'http.proxyPort',
+ 'https.proxyPort'
+ ]
+ Set textExtensions = ['class', 'json', 'yml', 'yaml', 'properties', 'toml', 'txt', 'xml', 'mf'] as Set
- from("LICENSE") {
- rename { "${it}_${inputs.properties.archivesName}"}
- }
- from("MCNG_LICENSE") {
- rename { "${it}_${inputs.properties.archivesName}"}
- }
-}
+ releaseArtifacts.each { artifact ->
+ File archive = new File(target, artifact.fileName.toString())
+ JarFile jar = new JarFile(archive)
+ try {
+ artifact.requiredEntries.each { required ->
+ if (jar.getJarEntry(required) == null) {
+ throw new GradleException("${archive.name} is missing ${required}")
+ }
+ }
+ byte[] classBytes = readEntry(jar, artifact.classEntry)
+ if (classBytes.length < 8) throw new GradleException("Invalid class file in ${archive.name}")
+ int major = ((classBytes[6] & 0xFF) << 8) | (classBytes[7] & 0xFF)
+ if (major != artifact.classMajor) {
+ throw new GradleException("${archive.name} class major is ${major}; expected ${artifact.classMajor}")
+ }
-shadowJar {
- archiveClassifier = "dev-shadow"
- configurations = [project.configurations.shadowBundle]
- from sourceSets.client.output
- from("LICENSE") {
- rename { "${it}_${project.base.archivesName.get()}"}
- }
- from("MCNG_LICENSE") {
- rename { "${it}_${project.base.archivesName.get()}"}
- }
- relocate "com.github.squi2rel.mcng", "com.github.squi2rel.vp.shadow.mcng"
-}
+ if (artifact.type == 'fabric') {
+ def metadata = new JsonSlurper().parseText(new String(readEntry(jar, 'fabric.mod.json'), StandardCharsets.UTF_8))
+ if (metadata.id != 'videoplayer') throw new GradleException("${archive.name} has invalid mod id ${metadata.id}")
+ if (metadata.version != releaseVersion) throw new GradleException("${archive.name} has internal version ${metadata.version}")
+ if (!(metadata.authors instanceof Collection) || !metadata.authors.contains('cloudfl4re')) {
+ throw new GradleException("${archive.name} authors do not contain cloudfl4re")
+ }
+ if (metadata.depends?.minecraft != artifact.minecraft) {
+ throw new GradleException("${archive.name} targets Minecraft ${metadata.depends?.minecraft}; expected ${artifact.minecraft}")
+ }
+ if (metadata.depends?.java != artifact.javaDependency) {
+ throw new GradleException("${archive.name} requires Java ${metadata.depends?.java}; expected ${artifact.javaDependency}")
+ }
+ } else {
+ String plugin = new String(readEntry(jar, 'plugin.yml'), StandardCharsets.UTF_8)
+ if (yamlValue(plugin, 'name') != 'VideoPlayer') throw new GradleException("${archive.name} has an invalid plugin name")
+ if (yamlValue(plugin, 'main') != 'com.github.squi2rel.vp.VideoPlayerPaperPlugin') {
+ throw new GradleException("${archive.name} has an invalid main class")
+ }
+ if (yamlValue(plugin, 'version') != releaseVersion) throw new GradleException("${archive.name} has an invalid internal version")
+ if (yamlValue(plugin, 'api-version') != artifact.apiVersion) {
+ throw new GradleException("${archive.name} has api-version ${yamlValue(plugin, 'api-version')}; expected ${artifact.apiVersion}")
+ }
+ if (yamlValue(plugin, 'folia-supported') != 'true') throw new GradleException("${archive.name} is not marked Folia-supported")
+ if (!yamlValue(plugin, 'authors').contains('cloudfl4re')) throw new GradleException("${archive.name} authors do not contain cloudfl4re")
+ }
-remapJar {
- dependsOn shadowJar
- inputFile.set(shadowJar.archiveFile)
-}
+ jar.entries().each { entry ->
+ if (entry.directory) return
+ String extension = entry.name.contains('.') ? entry.name.substring(entry.name.lastIndexOf('.') + 1).toLowerCase(Locale.ROOT) : ''
+ if (!textExtensions.contains(extension)) return
+ String content = new String(jar.getInputStream(entry).withCloseable { it.readAllBytes() }, StandardCharsets.ISO_8859_1)
+ String forbidden = forbiddenBuildValues.find { content.contains(it) }
+ if (forbidden != null) throw new GradleException("${archive.name}!/${entry.name} contains forbidden build value ${forbidden}")
+ }
+ } finally {
+ jar.close()
+ }
-// configure the maven publication
-publishing {
- publications {
- create("mavenJava", MavenPublication) {
- artifactId = project.archives_base_name
- from components.java
+ URLClassLoader loader = new URLClassLoader([archive.toURI().toURL()] as URL[], ClassLoader.getPlatformClassLoader())
+ try {
+ Class> protocol = Class.forName('com.github.squi2rel.vp.network.VideoProtocol', true, loader)
+ String token = protocol.getMethod('token', String.class).invoke(null, releaseVersion)
+ String revision = protocol.getField('WIRE_REVISION').get(null)
+ int maxBytes = protocol.getField('MAX_TOKEN_BYTES').getInt(null)
+ if (revision != 'vp5' || token != "${releaseVersion}|vp5") {
+ throw new GradleException("${archive.name} has protocol token ${token} and revision ${revision}")
+ }
+ if (token.getBytes(StandardCharsets.UTF_8).length > maxBytes || maxBytes != 16) {
+ throw new GradleException("${archive.name} protocol token exceeds the expected 16-byte limit")
+ }
+ } finally {
+ loader.close()
+ }
}
- }
-
- // See https://docs.gradle.org/current/userguide/publishing_maven.html for information on how to set up publishing.
- repositories {
- // Add repositories to publish to here.
- // Notice: This block does NOT have the same function as the block in the top level.
- // The repositories here will be used for publishing your artifact, not for
- // retrieving dependencies.
+ logger.lifecycle("Verified release archives: ${expectedNames}")
}
}
diff --git a/fabric-1.21.11/build.gradle b/fabric-1.21.11/build.gradle
new file mode 100644
index 0000000..d55ff95
--- /dev/null
+++ b/fabric-1.21.11/build.gradle
@@ -0,0 +1,146 @@
+plugins {
+ id 'fabric-loom'
+ id 'com.gradleup.shadow'
+ id 'maven-publish'
+}
+
+base {
+ archivesName = rootProject.archives_base_name
+}
+
+repositories {
+ mavenCentral()
+ exclusiveContent {
+ forRepository {
+ maven {
+ name = 'Modrinth'
+ url = 'https://api.modrinth.com/maven'
+ }
+ }
+ filter {
+ includeGroup 'maven.modrinth'
+ }
+ }
+}
+
+configurations {
+ shadowBundle
+}
+
+loom {
+ splitEnvironmentSourceSets()
+ mods {
+ videoplayer {
+ sourceSet sourceSets.main
+ sourceSet sourceSets.client
+ }
+ }
+}
+
+sourceSets {
+ main {
+ java.setSrcDirs([rootProject.file('src/main/java')])
+ resources.setSrcDirs([rootProject.file('src/main/resources')])
+ }
+ client {
+ java.setSrcDirs([rootProject.file('src/client/java')])
+ resources.setSrcDirs([rootProject.file('src/client/resources')])
+ }
+ test {
+ java.setSrcDirs([rootProject.file('src/test/java')])
+ resources.setSrcDirs([rootProject.file('src/test/resources')])
+ }
+}
+
+dependencies {
+ minecraft "com.mojang:minecraft:${rootProject.minecraft_version}"
+ mappings loom.officialMojangMappings()
+ modImplementation "net.fabricmc:fabric-loader:${rootProject.loader_version}"
+ modImplementation "net.fabricmc.fabric-api:fabric-api:${rootProject.fabric_version}"
+ implementation 'net.java.dev.jna:jna:5.17.0'
+ implementation 'org.brotli:dec:0.1.2'
+ include 'org.brotli:dec:0.1.2'
+ implementation 'com.google.zxing:core:3.5.3'
+ include 'com.google.zxing:core:3.5.3'
+ implementation project(':mcng-core')
+ implementation project(path: ':mcng-fabric-client', configuration: 'namedElements')
+ shadowBundle project(':mcng-core')
+ shadowBundle project(path: ':mcng-fabric-client', configuration: 'namedElements')
+ modCompileOnly "maven.modrinth:modmenu:${rootProject.modmenu_version}"
+ modApi 'maven.modrinth:vivecraft:1.21.11-1.3.9-fabric'
+ runtimeOnly 'com.electronwill.night-config:core:3.6.6'
+ runtimeOnly 'com.electronwill.night-config:toml:3.6.6'
+ testImplementation platform('org.junit:junit-bom:5.11.4')
+ testImplementation 'org.junit.jupiter:junit-jupiter'
+ testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+}
+
+test {
+ useJUnitPlatform()
+}
+
+processResources {
+ doFirst {
+ java.nio.file.Files.deleteIfExists(destinationDir.toPath().resolve('assets/videoplayer/native/vlc/android_arm64-v8a.zip'))
+ java.nio.file.Files.deleteIfExists(destinationDir.toPath().resolve('assets/videoplayer/native/vlc/android_arm64-v8a.NOTICE.txt'))
+ }
+ inputs.properties([
+ version: project.version,
+ loader_min_version: rootProject.loader_version,
+ minecraft_dependency: '~1.21.11',
+ java_version: '21'
+ ])
+ filesMatching('fabric.mod.json') {
+ expand inputs.properties
+ }
+}
+
+tasks.withType(JavaCompile).configureEach {
+ options.release = 21
+}
+
+java {
+ toolchain {
+ languageVersion = JavaLanguageVersion.of(21)
+ }
+ withSourcesJar()
+ sourceCompatibility = JavaVersion.VERSION_21
+ targetCompatibility = JavaVersion.VERSION_21
+}
+
+jar {
+ inputs.property 'archivesName', project.base.archivesName
+ from(rootProject.file('LICENSE')) {
+ rename { "${it}_${inputs.properties.archivesName}" }
+ }
+ from(rootProject.file('MCNG_LICENSE')) {
+ rename { "${it}_${inputs.properties.archivesName}" }
+ }
+}
+
+shadowJar {
+ archiveClassifier = 'dev-shadow'
+ configurations = [project.configurations.shadowBundle]
+ from sourceSets.client.output
+ from(rootProject.file('LICENSE')) {
+ rename { "${it}_${project.base.archivesName.get()}" }
+ }
+ from(rootProject.file('MCNG_LICENSE')) {
+ rename { "${it}_${project.base.archivesName.get()}" }
+ }
+ relocate 'com.github.squi2rel.mcng', 'com.github.squi2rel.vp.shadow.mcng'
+}
+
+remapJar {
+ dependsOn shadowJar
+ inputFile.set(shadowJar.archiveFile)
+}
+
+publishing {
+ publications {
+ create('mavenJava', MavenPublication) {
+ artifactId = rootProject.archives_base_name
+ from components.java
+ }
+ }
+}
diff --git a/fabric-26.2/build.gradle b/fabric-26.2/build.gradle
new file mode 100644
index 0000000..541f9ea
--- /dev/null
+++ b/fabric-26.2/build.gradle
@@ -0,0 +1,215 @@
+plugins {
+ id 'net.fabricmc.fabric-loom'
+ id 'com.gradleup.shadow'
+ id 'maven-publish'
+}
+
+base {
+ archivesName = rootProject.archives_base_name
+}
+
+repositories {
+ mavenCentral()
+ exclusiveContent {
+ forRepository {
+ maven {
+ name = 'Modrinth'
+ url = 'https://api.modrinth.com/maven'
+ }
+ }
+ filter {
+ includeGroup 'maven.modrinth'
+ }
+ }
+}
+
+configurations {
+ shadowBundle
+ clientUnitTestClasspath {
+ canBeConsumed = false
+ canBeResolved = true
+ }
+}
+
+loom {
+ splitEnvironmentSourceSets()
+ mods {
+ videoplayer {
+ sourceSet sourceSets.main
+ sourceSet sourceSets.client
+ }
+ }
+}
+
+def sharedMainSources = fileTree(rootProject.file('src/main/java')) {
+ exclude 'com/github/squi2rel/vp/VideoPlayerMain.java'
+ exclude 'com/github/squi2rel/vp/network/VideoPayload.java'
+ exclude 'com/github/squi2rel/vp/network/ClientMessageBridge.java'
+}
+
+def versionClientRoot = file('src/client/java')
+def versionClientPaths = fileTree(versionClientRoot) {
+ include '**/*.java'
+}.files.collect {
+ versionClientRoot.toPath().relativize(it.toPath()).toString().replace('\\', '/')
+}
+def sharedClientSources = fileTree(rootProject.file('src/client/java')) {
+ exclude versionClientPaths
+}
+
+sourceSets {
+ main {
+ java.setSrcDirs([file('src/main/java')])
+ resources.setSrcDirs([rootProject.file('src/main/resources')])
+ }
+ client {
+ java.setSrcDirs([versionClientRoot])
+ resources.setSrcDirs([file('src/client/resources'), rootProject.file('src/client/resources')])
+ }
+ test {
+ java.setSrcDirs([rootProject.file('src/test/java')])
+ resources.setSrcDirs([rootProject.file('src/test/resources')])
+ }
+}
+
+dependencies {
+ minecraft "com.mojang:minecraft:${rootProject.minecraft_26_2_version}"
+ implementation "net.fabricmc:fabric-loader:${rootProject.loader_26_2_version}"
+ implementation "net.fabricmc.fabric-api:fabric-api:${rootProject.fabric_26_2_version}"
+ implementation 'net.java.dev.jna:jna:5.17.0'
+ implementation 'org.brotli:dec:0.1.2'
+ include 'org.brotli:dec:0.1.2'
+ shadowBundle 'org.brotli:dec:0.1.2'
+ implementation 'com.google.zxing:core:3.5.3'
+ include 'com.google.zxing:core:3.5.3'
+ shadowBundle 'com.google.zxing:core:3.5.3'
+ implementation project(':mcng-core')
+ implementation project(':mcng-fabric-client-26.2')
+ shadowBundle project(':mcng-core')
+ shadowBundle project(':mcng-fabric-client-26.2')
+ compileOnly "maven.modrinth:modmenu:${rootProject.modmenu_26_2_version}"
+ compileOnly "maven.modrinth:vivecraft:${rootProject.vivecraft_26_2_version}"
+ runtimeOnly 'com.electronwill.night-config:core:3.6.6'
+ runtimeOnly 'com.electronwill.night-config:toml:3.6.6'
+ testImplementation platform('org.junit:junit-bom:5.11.4')
+ testImplementation 'org.junit.jupiter:junit-jupiter'
+ testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+ clientUnitTestClasspath platform('org.junit:junit-bom:5.11.4')
+ clientUnitTestClasspath 'org.junit.jupiter:junit-jupiter'
+ clientUnitTestClasspath 'org.junit.platform:junit-platform-launcher'
+ clientUnitTestClasspath 'org.apiguardian:apiguardian-api:1.1.2'
+}
+
+test {
+ useJUnitPlatform()
+}
+
+def clientUnitTestOutput = layout.buildDirectory.dir('classes/java/clientUnitTest')
+
+tasks.register('compileClientUnitTest', JavaCompile) {
+ source file('src/main/java/com/github/squi2rel/vp/creation/TextInputFilter.java')
+ source file('src/main/java/com/github/squi2rel/vp/render/FrameRenderGeometry.java')
+ source file('src/main/java/com/github/squi2rel/vp/render/CameraRenderGuard.java')
+ source fileTree('src/test/java') { include '**/*.java' }
+ classpath = configurations.clientUnitTestClasspath
+ destinationDirectory = clientUnitTestOutput
+ options.release = 25
+}
+
+tasks.register('clientUnitTest', Test) {
+ group = 'verification'
+ dependsOn tasks.named('compileClientUnitTest')
+ testClassesDirs = files(clientUnitTestOutput)
+ classpath = files(clientUnitTestOutput) + configurations.clientUnitTestClasspath
+ useJUnitPlatform()
+}
+
+tasks.named('check') {
+ dependsOn tasks.named('clientUnitTest')
+}
+
+processResources {
+ doFirst {
+ java.nio.file.Files.deleteIfExists(destinationDir.toPath().resolve('assets/videoplayer/native/vlc/android_arm64-v8a.zip'))
+ java.nio.file.Files.deleteIfExists(destinationDir.toPath().resolve('assets/videoplayer/native/vlc/android_arm64-v8a.NOTICE.txt'))
+ }
+ inputs.properties([
+ version: project.version,
+ loader_min_version: rootProject.loader_26_2_version,
+ minecraft_dependency: '~26.2',
+ java_version: '25'
+ ])
+ filesMatching('fabric.mod.json') {
+ expand inputs.properties
+ }
+}
+
+processClientResources {
+ duplicatesStrategy = DuplicatesStrategy.EXCLUDE
+}
+
+tasks.withType(JavaCompile).configureEach {
+ options.release = 25
+}
+
+tasks.named('compileJava', JavaCompile) {
+ source sharedMainSources
+}
+
+tasks.named('compileClientJava', JavaCompile) {
+ source sharedClientSources
+}
+
+java {
+ toolchain {
+ languageVersion = JavaLanguageVersion.of(25)
+ }
+ withSourcesJar()
+ sourceCompatibility = JavaVersion.VERSION_25
+ targetCompatibility = JavaVersion.VERSION_25
+}
+
+tasks.withType(AbstractArchiveTask).configureEach {
+ archiveVersion = "${project.version}-26.2"
+}
+
+jar {
+ archiveClassifier = 'plain'
+ inputs.property 'archivesName', project.base.archivesName
+ from(rootProject.file('LICENSE')) {
+ rename { "${it}_${inputs.properties.archivesName}" }
+ }
+ from(rootProject.file('MCNG_LICENSE')) {
+ rename { "${it}_${inputs.properties.archivesName}" }
+ }
+}
+
+shadowJar {
+ archiveClassifier = ''
+ configurations = [project.configurations.shadowBundle]
+ from sourceSets.client.output
+ from(rootProject.file('LICENSE')) {
+ rename { "${it}_${project.base.archivesName.get()}" }
+ }
+ from(rootProject.file('MCNG_LICENSE')) {
+ rename { "${it}_${project.base.archivesName.get()}" }
+ }
+ relocate 'com.github.squi2rel.mcng', 'com.github.squi2rel.vp.shadow.mcng'
+}
+
+tasks.named('assemble') {
+ dependsOn tasks.named('shadowJar')
+}
+
+tasks.named('sourcesJar') {
+ duplicatesStrategy = DuplicatesStrategy.EXCLUDE
+}
+
+publishing {
+ publications {
+ create('mavenJava', MavenPublication) {
+ artifactId = rootProject.archives_base_name
+ from components.java
+ }
+ }
+}
diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/CameraRenderer.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/CameraRenderer.java
new file mode 100644
index 0000000..2836bb6
--- /dev/null
+++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/CameraRenderer.java
@@ -0,0 +1,56 @@
+package com.github.squi2rel.vp;
+
+import com.github.squi2rel.vp.mixin.client.GameRendererTargetAccessor;
+import com.github.squi2rel.vp.render.CameraRenderGuard;
+import com.mojang.blaze3d.pipeline.RenderTarget;
+import net.minecraft.client.Minecraft;
+import net.minecraft.world.entity.Entity;
+
+public final class CameraRenderer {
+ private static final CameraRenderGuard GUARD = new CameraRenderGuard();
+ public static int width;
+ public static int height;
+ public static int fov = 70;
+
+ private CameraRenderer() {
+ }
+
+ public static boolean isRendering() {
+ return GUARD.isRendering();
+ }
+
+ public static void renderWorld(Entity entity, RenderTarget framebuffer, int cameraFov) {
+ Minecraft client = Minecraft.getInstance();
+ if (client.level == null || entity == null || framebuffer == null) return;
+ CameraRenderGuard.Scope scope = GUARD.enter();
+ if (scope == null) return;
+ GameRendererTargetAccessor access = (GameRendererTargetAccessor) client.gameRenderer;
+ RenderTarget oldFramebuffer = access.videoplayer$getFramebuffer();
+ Entity oldCamera = client.getCameraEntity();
+ int oldWidth = width;
+ int oldHeight = height;
+ int oldFov = fov;
+ width = framebuffer.width;
+ height = framebuffer.height;
+ fov = Math.clamp(cameraFov, 1, 179);
+ try {
+ access.videoplayer$setFramebuffer(framebuffer);
+ client.setCameraEntity(entity);
+ client.gameRenderer.extract(client.getDeltaTracker(), true);
+ client.gameRenderer.renderLevel(client.getDeltaTracker());
+ } finally {
+ try {
+ client.setCameraEntity(oldCamera);
+ } finally {
+ try {
+ width = oldWidth;
+ height = oldHeight;
+ fov = oldFov;
+ access.videoplayer$setFramebuffer(oldFramebuffer);
+ } finally {
+ scope.close();
+ }
+ }
+ }
+ }
+}
diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/ClientPacketHandler.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/ClientPacketHandler.java
new file mode 100644
index 0000000..fd3dfda
--- /dev/null
+++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/ClientPacketHandler.java
@@ -0,0 +1,1057 @@
+package com.github.squi2rel.vp;
+
+import com.github.squi2rel.vp.network.ByteBufUtils;
+import com.github.squi2rel.vp.network.ClientPlaybackResolution;
+import com.github.squi2rel.vp.network.IdlePlayMutation;
+import com.github.squi2rel.vp.network.RequestResultStatus;
+import com.github.squi2rel.vp.network.VideoPacketType;
+import com.github.squi2rel.vp.network.VideoPackets;
+import com.github.squi2rel.vp.network.VideoPayload;
+import com.github.squi2rel.vp.network.VideoProtocol;
+import com.github.squi2rel.vp.i18n.VpTexts;
+import com.github.squi2rel.vp.i18n.VpTranslation;
+import com.github.squi2rel.vp.permission.VideoPermissionAction;
+import com.github.squi2rel.vp.provider.LocalPlaybackInfo;
+import com.github.squi2rel.vp.provider.NamedProviderSource;
+import com.github.squi2rel.vp.provider.VideoInfo;
+import com.github.squi2rel.vp.provider.VideoProviders;
+import com.github.squi2rel.vp.provider.VideoUrlNormalizer;
+import com.github.squi2rel.vp.provider.YouTubeProvider;
+import com.github.squi2rel.vp.provider.bilibili.BiliQuality;
+import com.github.squi2rel.vp.provider.bilibili.BiliBiliVideoProvider;
+import com.github.squi2rel.vp.provider.youtube.YouTubeQuality;
+import com.github.squi2rel.vp.video.ClientVideoArea;
+import com.github.squi2rel.vp.video.ClientVideoScreen;
+import com.github.squi2rel.vp.video.IVideoPlayer;
+import com.github.squi2rel.vp.video.IdlePlayEntry;
+import com.github.squi2rel.vp.video.MetaValue;
+import com.github.squi2rel.vp.video.PlaybackDiagnostics;
+import com.github.squi2rel.vp.video.ScreenMetadata;
+import com.github.squi2rel.vp.video.VideoArea;
+import com.github.squi2rel.vp.video.VideoListeners;
+import com.github.squi2rel.vp.video.VideoScreen;
+import com.mojang.brigadier.CommandDispatcher;
+import com.mojang.brigadier.exceptions.CommandSyntaxException;
+import io.netty.buffer.ByteBuf;
+import net.fabricmc.fabric.api.client.command.v2.ClientCommands;
+import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource;
+import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
+import net.minecraft.ChatFormatting;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.player.LocalPlayer;
+import org.joml.Vector3f;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.function.Consumer;
+
+import static com.github.squi2rel.vp.VideoPlayerClient.areas;
+import static com.github.squi2rel.vp.VideoPlayerMain.LOGGER;
+import static com.github.squi2rel.vp.network.ByteBufUtils.writeString;
+
+public class ClientPacketHandler {
+ private static final long REQUEST_TTL_MS = 60_000L;
+ private static final int MAX_PENDING_REPORTER_GRANTS = 256;
+ private static int nextRequestId = 1;
+ private static final Map pendingRequests = new HashMap<>();
+ private static final Map pendingReporterGrants = new HashMap<>();
+ private static final Map playbackDiagnostics = new HashMap<>();
+ private static String serverProtocolToken = "";
+
+ public static void handle(ByteBuf buf) {
+ handle(buf, System.currentTimeMillis());
+ }
+
+ public static void handle(ByteBuf buf, long receivedAt) {
+ cleanupPendingRequests();
+ cleanupPendingReporterGrants();
+ VideoPacketType type = VideoPackets.readType(buf);
+ if (type == null) {
+ LOGGER.warn("Unknown packet type");
+ return;
+ }
+ if (VideoPlayerClient.protocolRejected() && !VideoProtocol.allowedForRejectedClient(type)) {
+ return;
+ }
+
+ switch (type) {
+ case CONFIG -> handleConfig(buf, false);
+ case REQUEST -> handleRequest(buf, receivedAt);
+ case SYNC -> {
+ String areaName = VideoPackets.readName(buf);
+ String screenName = VideoPackets.readName(buf);
+ long generation = buf.readLong();
+ long progress = buf.readLong();
+ ClientVideoScreen screen = screenOrNull(areaName, screenName);
+ if (screen != null && screen.acceptServerPlaybackGeneration(generation)) screen.setProgress(progress);
+ }
+ case CREATE_AREA -> areas.put(VideoPackets.readName(buf), ClientVideoArea.read(buf));
+ case REMOVE_AREA -> {
+ String areaName = VideoPackets.readName(buf);
+ ClientVideoArea area = areas.remove(areaName);
+ ClientPermissionCache.removeArea(areaName);
+ removeDiagnosticsArea(areaName);
+ if (area != null) {
+ area.remove();
+ }
+ }
+ case CREATE_SCREEN -> handleCreateScreen(buf);
+ case REMOVE_SCREEN -> {
+ String areaName = VideoPackets.readName(buf);
+ String screenName = VideoPackets.readName(buf);
+ ClientVideoArea area = areaOrNull(areaName);
+ if (area != null) {
+ area.remove(screenName);
+ }
+ ClientPermissionCache.removeScreen(areaName, screenName);
+ playbackDiagnostics.remove(new DiagnosticsKey(normalize(areaName), normalize(screenName)));
+ }
+ case LOAD_AREA -> handleLoadArea(buf, receivedAt);
+ case UNLOAD_AREA -> {
+ ClientVideoArea area = areaOrNull(VideoPackets.readName(buf));
+ if (area != null) area.unload();
+ }
+ case UPDATE_PLAYLIST -> handleUpdatePlaylist(buf);
+ case SKIP -> {
+ String areaName = VideoPackets.readName(buf);
+ String screenName = VideoPackets.readName(buf);
+ long generation = buf.readLong();
+ ClientVideoScreen screen = screenOrNull(areaName, screenName);
+ if (screen == null) return;
+ if (!screen.acceptServerPlaybackGeneration(generation)) return;
+ IVideoPlayer player = screen.player;
+ screen.clearPlaybackState();
+ if (player != null) Minecraft.getInstance().execute(player::stop);
+ }
+ case EXECUTE -> handleExecute(buf);
+ case IDLE_PLAY -> {
+ String areaName = VideoPackets.readName(buf);
+ String screenName = VideoPackets.readName(buf);
+ ClientVideoScreen screen = screenOrNull(areaName, screenName);
+ if (VideoProtocol.supportsIdlePlayMutations(serverProtocolToken)) {
+ if (screen == null) {
+ VideoScreen discard = new VideoScreen(null, screenName, List.of(), "");
+ VideoPackets.readIdlePlayConfig(buf, discard);
+ return;
+ }
+ VideoPackets.readIdlePlayConfig(buf, screen);
+ } else {
+ VideoPackets.LegacyIdlePlayConfig legacy = VideoPackets.readLegacyIdlePlayConfig(buf);
+ if (screen != null) {
+ screen.replaceLegacyIdlePlayConfig(
+ legacy.urls(), legacy.random(), IdlePlayEntry.UNKNOWN_UUID, ""
+ );
+ }
+ }
+ }
+ case SET_UV -> {
+ String areaName = VideoPackets.readName(buf);
+ String screenName = VideoPackets.readName(buf);
+ float u1 = buf.readFloat();
+ float v1 = buf.readFloat();
+ float u2 = buf.readFloat();
+ float v2 = buf.readFloat();
+ ClientVideoScreen screen = screenOrNull(areaName, screenName);
+ if (screen == null) return;
+ if (!finite(u1, v1, u2, v2)) return;
+ screen.u1 = u1;
+ screen.v1 = v1;
+ screen.u2 = u2;
+ screen.v2 = v2;
+ }
+ case SET_SCREEN_METADATA -> {
+ String areaName = VideoPackets.readName(buf);
+ String screenName = VideoPackets.readName(buf);
+ String key = ByteBufUtils.readString(buf, 64);
+ boolean remove = buf.readBoolean();
+ MetaValue value = remove ? null : VideoPackets.readMetaValue(buf);
+ ClientVideoScreen screen = screenOrNull(areaName, screenName);
+ if (screen == null) return;
+ if (remove) {
+ screen.metadata.remove(key);
+ } else {
+ screen.metadata.set(key, value);
+ }
+ screen.metadataChanged();
+ if (ScreenMetadata.KEY_BILIBILI_QUALITY.equals(key)
+ || ScreenMetadata.KEY_YOUTUBE_QUALITY.equals(key)) {
+ reloadQualityPlayback(screen);
+ }
+ }
+ case SET_SCALE -> {
+ String areaName = VideoPackets.readName(buf);
+ String screenName = VideoPackets.readName(buf);
+ boolean fill = buf.readBoolean();
+ float scaleX = buf.readFloat();
+ float scaleY = buf.readFloat();
+ ClientVideoScreen screen = screenOrNull(areaName, screenName);
+ if (screen == null) return;
+ if (!finite(scaleX, scaleY)) return;
+ screen.fill = fill;
+ screen.scaleX = scaleX;
+ screen.scaleY = scaleY;
+ }
+ case AUTO_SYNC -> {
+ String areaName = VideoPackets.readName(buf);
+ String screenName = VideoPackets.readName(buf);
+ long generation = buf.readLong();
+ long clientTime = buf.readLong();
+ long progress = buf.readLong();
+ long serverDelay = Math.max(0L, buf.readLong());
+ ClientVideoScreen screen = screenOrNull(areaName, screenName);
+ if (screen == null) return;
+ if (generation != screen.serverPlaybackGeneration()) return;
+ long roundTrip = Math.max(0L, receivedAt - clientTime - serverDelay);
+ screen.autoSync(roundTrip, progress);
+ }
+ case UPDATE_SCREEN -> handleUpdateScreen(buf);
+ case REQUEST_RESULT -> handleRequestResult(buf);
+ case PERMISSIONS -> handlePermissions(buf);
+ case RESET_CLIENT -> handleConfig(buf, true);
+ case PROTOCOL_REJECT -> handleProtocolReject(buf);
+ case CLIENT_PLAYBACK_RESOLVED -> {
+ }
+ case CLIENT_PLAYBACK_REPORTER -> handleClientPlaybackReporter(buf);
+ case DIAGNOSTICS -> handleDiagnostics(buf);
+ case PLAYBACK_NOTICE -> handlePlaybackNotice(buf);
+ default -> LOGGER.warn("Unknown packet type: {}", type);
+ }
+
+ if (buf.readableBytes() > 0) {
+ LOGGER.warn("Bytes remaining: {}, type {}", buf.readableBytes(), type);
+ }
+ }
+
+ private static void handleRequestResult(ByteBuf buf) {
+ int requestId = buf.readInt();
+ RequestResultStatus status = RequestResultStatus.fromId(buf.readUnsignedByte());
+ VpTranslation message = VideoPackets.readTranslation(buf);
+ PendingRequest pending = pendingRequests.remove(requestId);
+ if (pending == null) return;
+ if (cacheRequestPermission(pending.action())) {
+ if (status == RequestResultStatus.DENIED) {
+ ClientPermissionCache.setAllowed(pending.action(), pending.areaName(), pending.screenName(), false);
+ } else if (status == RequestResultStatus.OK) {
+ ClientPermissionCache.setAllowed(pending.action(), pending.areaName(), pending.screenName(), true);
+ }
+ }
+ if ((status == RequestResultStatus.ERROR || status == RequestResultStatus.DENIED)
+ && message != null && !message.isEmpty()) {
+ LocalPlayer player = Minecraft.getInstance().player;
+ if (player != null) player.sendSystemMessage(VpTexts.text(message).copy().withStyle(ChatFormatting.RED));
+ }
+ if (pending.callback() != null) {
+ pending.callback().accept(new RequestResult(requestId, status, message));
+ }
+ }
+
+ private static void handleDiagnostics(ByteBuf buf) {
+ String areaName = VideoPackets.readName(buf);
+ String screenName = VideoPackets.readName(buf);
+ PlaybackDiagnostics diagnostics = VideoPackets.readDiagnostics(buf);
+ playbackDiagnostics.put(new DiagnosticsKey(normalize(areaName), normalize(screenName)),
+ new TimedDiagnostics(diagnostics, System.currentTimeMillis()));
+ }
+
+ private static void handlePlaybackNotice(ByteBuf buf) {
+ VideoPackets.readName(buf);
+ VideoPackets.readName(buf);
+ boolean error = buf.readBoolean();
+ VpTranslation message = VideoPackets.readTranslation(buf);
+ LocalPlayer player = Minecraft.getInstance().player;
+ if (player != null && message != null && !message.isEmpty()) {
+ player.sendSystemMessage(VpTexts.text(message).copy().withStyle(error ? ChatFormatting.RED : ChatFormatting.YELLOW));
+ }
+ }
+
+ private static boolean cacheRequestPermission(VideoPermissionAction action) {
+ return switch (action) {
+ case CREATE_AREA, CREATE_SCREEN, UPDATE_SCREEN -> false;
+ default -> true;
+ };
+ }
+
+ private static void handlePermissions(ByteBuf buf) {
+ String areaName = VideoPackets.readName(buf);
+ String screenName = VideoPackets.readName(buf);
+ long allowedMask = buf.readLong();
+ ClientPermissionCache.update(areaName, screenName, allowedMask);
+ }
+
+ private static void handleConfig(ByteBuf buf, boolean reset) {
+ String remoteToken = ByteBufUtils.readString(buf, 16);
+ if (reset) VideoPlayerClient.resetServerState();
+ if (!VideoProtocol.compatible(VideoPlayerMain.version, remoteToken)) {
+ VideoPlayerClient.rejectProtocol(VideoProtocol.displayVersion(remoteToken));
+ return;
+ }
+ String remoteVersion = VideoProtocol.displayVersion(remoteToken);
+ VideoPlayerClient.acceptProtocol();
+ serverProtocolToken = remoteToken;
+ VideoPlayerClient.handshakeResponse(remoteVersion);
+ VideoPlayerClient.remoteControlName = ByteBufUtils.readString(buf, 256);
+ VideoPlayerClient.remoteControlId = buf.readFloat();
+ VideoPlayerClient.remoteControlRange = buf.readFloat();
+ VideoPlayerClient.noControlRange = buf.readFloat();
+ if (reset) {
+ long nonce = buf.readLong();
+ if (nonce == 0L) {
+ VideoPlayerClient.rejectProtocol("invalid handshake reset");
+ return;
+ }
+ VideoPlayerClient.serverHandshakeReset();
+ VideoPlayerClient.setHandshakeNonce(nonce);
+ handshakeAck(nonce);
+ config(VideoPlayerMain.version);
+ VideoPlayerClient.connected = false;
+ } else {
+ VideoPlayerClient.setHandshakeNonce(0L);
+ VideoPlayerClient.connected = true;
+ VideoPlayerClient.connectionEstablished(remoteVersion);
+ }
+ }
+
+ private static void handleProtocolReject(ByteBuf buf) {
+ String remoteToken = ByteBufUtils.readString(buf, 16);
+ VideoPlayerClient.rejectProtocol(VideoProtocol.displayVersion(remoteToken));
+ }
+
+ private static void handleClientPlaybackReporter(ByteBuf buf) {
+ String areaName = VideoPackets.readName(buf);
+ String screenName = VideoPackets.readName(buf);
+ long generation = buf.readLong();
+ long reporterToken = buf.readLong();
+ ClientVideoArea area = areas.get(areaName);
+ ClientVideoScreen screen = area == null ? null : area.getScreen(screenName);
+ if (screen == null) {
+ storePendingReporterGrant(areaName, screenName, generation, reporterToken);
+ return;
+ }
+ screen.setServerPlaybackReporter(generation, reporterToken);
+ VideoInfo requested = screen.serverPlaybackRequestInfo(generation);
+ if (requested != null && screen.hasServerPlaybackResolution(generation)) {
+ reportClientPlaybackResolution(screen, requested, generation,
+ screen.serverPlaybackResolutionInfo(generation));
+ }
+ }
+
+ private static void handleRequest(ByteBuf buf, long receivedAt) {
+ String areaName = VideoPackets.readName(buf);
+ String screenName = VideoPackets.readName(buf);
+ long generation = buf.readLong();
+ long progress = buf.readLong();
+ long serverSentAt = buf.readLong();
+ VideoInfo info = VideoInfo.read(buf);
+ boolean idle = buf.readBoolean();
+ ClientVideoScreen screen = screenOrNull(areaName, screenName);
+ if (screen == null) return;
+ LocalPlayer player = Minecraft.getInstance().player;
+ if (player == null) return;
+ int playbackToken = screen.beginServerPlaybackRequest(generation);
+ if (playbackToken < 0) return;
+ applyPendingReporterGrant(areaName, screenName, generation, screen);
+ screen.setServerPlaybackRequestInfo(generation, info);
+ CompletableFuture video = resolveForLocalPlayback(screen, info);
+ screen.trackPlaybackFuture(playbackToken, video);
+ video.whenComplete((v, error) -> {
+ if (error != null || v == null) {
+ Minecraft.getInstance().execute(() -> {
+ if (screen.canAcceptPlayback(playbackToken)) {
+ screen.setServerPlaybackResolution(generation, null);
+ reportClientPlaybackResolution(screen, info, generation, null);
+ screen.failPlaybackRequest(playbackToken);
+ player.sendSystemMessage(VpTexts.tr("message.videoplayer.source_unresolved", "Unable to resolve video source"));
+ }
+ });
+ return;
+ }
+ Minecraft.getInstance().execute(() -> {
+ if (!screen.canAcceptPlayback(playbackToken)) return;
+ if (v.seekable() && progress >= 0) {
+ long transport = Math.max(0L, receivedAt - serverSentAt);
+ transport = Math.min(transport, 30_000L);
+ screen.setToSeek(progress + transport + Math.max(0L, System.currentTimeMillis() - receivedAt));
+ }
+ screen.setServerPlaybackResolution(generation, v);
+ screen.play(v, idle);
+ reportClientPlaybackResolution(screen, info, generation, v);
+ });
+ });
+ }
+
+ private static void reportClientPlaybackResolution(ClientVideoScreen screen, VideoInfo requested,
+ long generation, VideoInfo resolved) {
+ if (screen == null || screen.serverPlaybackGeneration() != generation
+ || !VideoListeners.awaitsClientPlaybackResolution(requested)) {
+ return;
+ }
+ long reporterToken = screen.serverPlaybackReporterToken(generation);
+ if (reporterToken == 0L) return;
+ ClientPlaybackResolution resolution;
+ long durationMs = 0L;
+ if (resolved == null) {
+ resolution = ClientPlaybackResolution.FAILED;
+ } else if (!resolved.seekable()) {
+ resolution = ClientPlaybackResolution.LIVE;
+ } else if (resolved.durationMs() > 0) {
+ resolution = ClientPlaybackResolution.FINITE;
+ durationMs = resolved.durationMs();
+ } else {
+ resolution = ClientPlaybackResolution.FAILED;
+ }
+ send(VideoPackets.clientPlaybackResolved(screen, generation, reporterToken, resolution, durationMs));
+ }
+
+ static CompletableFuture resolveForLocalPlayback(ClientVideoScreen screen, VideoInfo info) {
+ if (info == null) return CompletableFuture.completedFuture(null);
+ if (info.rawPath() == null || info.rawPath().isEmpty()) return CompletableFuture.completedFuture(info);
+ CompletableFuture video = resolveLocalProvider(screen, info);
+ if (video == null) return CompletableFuture.completedFuture(LocalPlaybackInfo.select(info, null));
+ CompletableFuture selected = new CompletableFuture<>();
+ video.whenComplete((resolved, error) -> {
+ if (error != null
+ && !(error instanceof java.util.concurrent.CancellationException)
+ && !(error.getCause() instanceof java.util.concurrent.CancellationException)) {
+ LOGGER.warn("Failed to resolve local playback source {}", VideoProviders.redactedSource(info.rawPath()), error);
+ }
+ if (!selected.isCancelled()) selected.complete(LocalPlaybackInfo.select(info, resolved));
+ });
+ selected.whenComplete((resolved, error) -> {
+ if (selected.isCancelled()) video.cancel(true);
+ });
+ return selected;
+ }
+
+ private static CompletableFuture resolveLocalProvider(ClientVideoScreen screen, VideoInfo info) {
+ if (!LocalPlaybackResolutionPolicy.shouldResolve(info)) return null;
+ int localQuality = VideoPlayerClient.config == null ? BiliQuality.DEFAULT_QN : VideoPlayerClient.config.bilibiliQuality;
+ int screenLimit = screen == null || screen.metadata == null
+ ? BiliQuality.UNLIMITED
+ : screen.metadata.getInt(ScreenMetadata.KEY_BILIBILI_QUALITY, BiliQuality.UNLIMITED);
+ int bilibiliQuality = BiliQuality.effective(localQuality, screenLimit);
+ int localYoutube = VideoPlayerClient.config == null ? YouTubeQuality.AUTO : VideoPlayerClient.config.youtubeQuality;
+ int youtubeLimit = screen == null || screen.metadata == null
+ ? YouTubeQuality.AUTO
+ : screen.metadata.getInt(ScreenMetadata.KEY_YOUTUBE_QUALITY, YouTubeQuality.AUTO);
+ int youtubeQuality = YouTubeQuality.effective(localYoutube, youtubeLimit);
+ return VideoProviders.from(info.rawPath(), new NamedProviderSource(info.playerName(), bilibiliQuality, youtubeQuality));
+ }
+
+ public static void reloadQualityPlayback(ClientVideoScreen screen) {
+ if (screen == null || screen.player == null) return;
+ VideoInfo current = screen.currentPlaybackInfo();
+ if (current == null || current.rawPath() == null || current.rawPath().isBlank()) return;
+ if (!BiliBiliVideoProvider.isBiliVideoRawPath(current.rawPath())
+ && !YouTubeProvider.isYouTubeRawPath(current.rawPath())) return;
+ long progress = Math.max(0L, screen.player.getProgress());
+ boolean idle = screen.isIdlePlaying();
+ boolean paused = screen.player.isPaused();
+ int playbackToken = screen.beginPlaybackRequest();
+ CompletableFuture video = resolveLocalProvider(screen, current);
+ if (video == null) {
+ screen.failPlaybackRequest(playbackToken);
+ return;
+ }
+ screen.trackPlaybackFuture(playbackToken, video);
+ video.whenComplete((resolved, error) -> {
+ if (error != null) {
+ LOGGER.warn("Failed to reload quality-limited source {}", VideoProviders.redactedSource(current.rawPath()), error);
+ Minecraft.getInstance().execute(() -> screen.failPlaybackRequest(playbackToken));
+ return;
+ }
+ if (!LocalPlaybackInfo.playable(resolved)) {
+ Minecraft.getInstance().execute(() -> screen.failPlaybackRequest(playbackToken));
+ return;
+ }
+ VideoInfo selected = LocalPlaybackInfo.select(current, resolved);
+ Minecraft.getInstance().execute(() -> {
+ if (!screen.canAcceptPlayback(playbackToken)) return;
+ if (progress > 0) screen.setToSeek(progress);
+ screen.play(selected, idle);
+ if (paused && screen.player != null) {
+ screen.player.pause(true);
+ }
+ });
+ });
+ }
+
+ private static void handleCreateScreen(ByteBuf buf) {
+ ClientVideoArea area = areaOrNull(VideoPackets.readName(buf));
+ short size = buf.readUnsignedByte();
+ if (size > VideoArea.MAX_SCREENS) throw new IllegalStateException("Video screen count exceeds " + VideoArea.MAX_SCREENS);
+ for (int i = 0; i < size; i++) {
+ VideoScreen base = VideoScreen.read(buf, area);
+ if (area == null) {
+ VideoPackets.readUv(buf, base);
+ VideoPackets.readScale(buf, base);
+ continue;
+ }
+ ClientVideoScreen screen = ClientVideoScreen.from(base);
+ VideoPackets.readUv(buf, screen);
+ VideoPackets.readScale(buf, screen);
+ screen.metadataChanged();
+ area.addScreen(screen);
+ }
+ }
+
+ private static void handleLoadArea(ByteBuf buf, long receivedAt) {
+ String areaName = VideoPackets.readName(buf);
+ ClientVideoArea area = areaOrNull(areaName);
+ while (buf.readableBytes() != 0) {
+ String screenName = VideoPackets.readName(buf);
+ long generation = buf.readLong();
+ VideoInfo info = VideoInfo.read(buf);
+ long seek = buf.readLong();
+ boolean idle = buf.readBoolean();
+ if (area == null) continue;
+ ClientVideoScreen screen = area.getScreen(screenName);
+ if (screen == null) continue;
+ int playbackToken = screen.beginServerPlaybackRequest(generation);
+ if (playbackToken < 0) continue;
+ applyPendingReporterGrant(areaName, screenName, generation, screen);
+ screen.setServerPlaybackRequestInfo(generation, info);
+ CompletableFuture video = resolveForLocalPlayback(screen, info);
+ screen.trackPlaybackFuture(playbackToken, video);
+ video.whenComplete((resolved, error) -> {
+ if (error != null || resolved == null) {
+ Minecraft.getInstance().execute(() -> {
+ if (!screen.isPlaybackRequestCurrent(playbackToken)) return;
+ screen.setServerPlaybackResolution(generation, null);
+ reportClientPlaybackResolution(screen, info, generation, null);
+ screen.failPlaybackRequest(playbackToken);
+ });
+ return;
+ }
+ Minecraft.getInstance().execute(() -> {
+ if (!screen.isPlaybackRequestCurrent(playbackToken)) return;
+ if (resolved.seekable() && seek >= 0) {
+ screen.setToSeek(seek + Math.max(0L, System.currentTimeMillis() - receivedAt));
+ }
+ screen.setServerPlaybackResolution(generation, resolved);
+ screen.setToPlay(resolved, idle);
+ if (screen.canAcceptPlayback(playbackToken)) {
+ screen.play(resolved, idle);
+ reportClientPlaybackResolution(screen, info, generation, resolved);
+ }
+ });
+ });
+ }
+ if (area != null) area.load();
+ }
+
+ private static void handleUpdatePlaylist(ByteBuf buf) {
+ ClientVideoArea area = areaOrNull(VideoPackets.readName(buf));
+ short size = buf.readUnsignedByte();
+ if (size > VideoArea.MAX_SCREENS) throw new IllegalStateException("Playlist screen count exceeds " + VideoArea.MAX_SCREENS);
+ for (int i = 0; i < size; i++) {
+ String screenName = VideoPackets.readName(buf);
+ short len = buf.readUnsignedByte();
+ if (len > com.github.squi2rel.vp.video.PlaybackQueue.MAX_ITEMS) {
+ throw new IllegalStateException("Video queue exceeds " + com.github.squi2rel.vp.video.PlaybackQueue.MAX_ITEMS + " items");
+ }
+ VideoInfo[] infos = new VideoInfo[len];
+ for (int j = 0; j < len; j++) {
+ String playerName = ByteBufUtils.readString(buf, 256);
+ String name = ByteBufUtils.readString(buf, 256);
+ boolean seekable = buf.readBoolean();
+ infos[j] = new VideoInfo(playerName, name, null, null, -1, seekable, null);
+ }
+ if (area == null) continue;
+ ClientVideoScreen screen = area.getScreen(screenName);
+ if (screen != null) screen.updatePlaylist(infos);
+ }
+ }
+
+ private static void handleExecute(ByteBuf buf) {
+ Minecraft client = Minecraft.getInstance();
+ CommandDispatcher dispatcher = ClientCommands.getActiveDispatcher();
+ if (dispatcher == null || client.player == null) return;
+ try {
+ dispatcher.execute("vlc " + ByteBufUtils.readString(buf, 1024), (FabricClientCommandSource) client.player.connection.getSuggestionsProvider());
+ } catch (CommandSyntaxException e) {
+ client.player.sendSystemMessage(VpTexts.tr("message.videoplayer.command_failed", "Command failed: %s", e).withStyle(ChatFormatting.RED));
+ }
+ }
+
+ private static void handleUpdateScreen(ByteBuf buf) {
+ String areaName = VideoPackets.readName(buf);
+ String screenName = VideoPackets.readName(buf);
+ short vertexCount = buf.readUnsignedByte();
+ ArrayList vertices = new ArrayList<>(vertexCount);
+ for (int i = 0; i < vertexCount; i++) {
+ vertices.add(ByteBufUtils.readVec3(buf));
+ }
+ String source = VideoPackets.readName(buf);
+ VideoScreen displayConfig = new VideoScreen(null, screenName, vertices, source);
+ VideoScreen.readDisplayConfig(buf, displayConfig);
+ ClientVideoScreen screen = screenOrNull(areaName, screenName);
+ if (screen != null) screen.applyUpdate(vertices, source, displayConfig);
+ }
+
+ private static void send(byte[] bytes) {
+ ClientPlayNetworking.send(new VideoPayload(bytes));
+ }
+
+ private static boolean finite(float... values) {
+ for (float value : values) {
+ if (!Float.isFinite(value)) return false;
+ }
+ return true;
+ }
+
+ private static long serverGeneration(VideoScreen screen) {
+ return screen instanceof ClientVideoScreen clientScreen ? clientScreen.serverPlaybackGeneration() : 0L;
+ }
+
+ private static ByteBuf controlled(VideoPacketType type, VideoPermissionAction action, String areaName, String screenName,
+ Consumer callback) {
+ cleanupPendingRequests();
+ int requestId = nextRequestId();
+ ByteBuf buf = VideoPackets.create(type);
+ buf.writeInt(requestId);
+ pendingRequests.put(requestId, new PendingRequest(action, normalize(areaName), normalize(screenName), callback, System.currentTimeMillis()));
+ return buf;
+ }
+
+ private static int nextRequestId() {
+ int requestId = nextRequestId++;
+ if (requestId <= 0) {
+ nextRequestId = 2;
+ requestId = 1;
+ }
+ return requestId;
+ }
+
+ private static void cleanupPendingRequests() {
+ long now = System.currentTimeMillis();
+ Iterator> iterator = pendingRequests.entrySet().iterator();
+ while (iterator.hasNext()) {
+ Map.Entry entry = iterator.next();
+ if (now - entry.getValue().createdAt() <= REQUEST_TTL_MS) continue;
+ iterator.remove();
+ Consumer callback = entry.getValue().callback();
+ if (callback != null) {
+ callback.accept(new RequestResult(entry.getKey(), RequestResultStatus.ERROR,
+ VpTranslation.of("error.videoplayer.request_timeout", "VideoPlayer request timed out")));
+ }
+ }
+ }
+
+ private static void storePendingReporterGrant(String areaName, String screenName, long generation, long reporterToken) {
+ if (generation < 0 || reporterToken == 0L) return;
+ cleanupPendingReporterGrants();
+ if (pendingReporterGrants.size() >= MAX_PENDING_REPORTER_GRANTS) return;
+ ReporterGrantKey key = new ReporterGrantKey(normalize(areaName), normalize(screenName), generation);
+ pendingReporterGrants.put(key, new PendingReporterGrant(reporterToken, System.currentTimeMillis()));
+ }
+
+ private static void applyPendingReporterGrant(String areaName, String screenName, long generation,
+ ClientVideoScreen screen) {
+ if (screen == null) return;
+ ReporterGrantKey key = new ReporterGrantKey(normalize(areaName), normalize(screenName), generation);
+ PendingReporterGrant grant = pendingReporterGrants.remove(key);
+ if (grant != null) screen.setServerPlaybackReporter(generation, grant.token());
+ }
+
+ private static void cleanupPendingReporterGrants() {
+ long now = System.currentTimeMillis();
+ pendingReporterGrants.entrySet().removeIf(entry -> now - entry.getValue().createdAt() > REQUEST_TTL_MS);
+ }
+
+ private static void cleanupPlaybackDiagnostics() {
+ long now = System.currentTimeMillis();
+ playbackDiagnostics.entrySet().removeIf(entry -> now - entry.getValue().receivedAt() > REQUEST_TTL_MS);
+ }
+
+ private static void removeDiagnosticsArea(String areaName) {
+ String normalized = normalize(areaName);
+ playbackDiagnostics.keySet().removeIf(key -> key.areaName().equals(normalized));
+ }
+
+ public static void resetPendingRequests() {
+ pendingReporterGrants.clear();
+ playbackDiagnostics.clear();
+ serverProtocolToken = "";
+ if (pendingRequests.isEmpty()) return;
+ ArrayList> pending = new ArrayList<>(pendingRequests.entrySet());
+ pendingRequests.clear();
+ VpTranslation message = VpTranslation.of("error.videoplayer.server_state_reset", "VideoPlayer server state was reset");
+ for (Map.Entry entry : pending) {
+ Consumer callback = entry.getValue().callback();
+ if (callback != null) {
+ callback.accept(new RequestResult(entry.getKey(), RequestResultStatus.ERROR, message));
+ }
+ }
+ }
+
+ public static void tickPendingRequests() {
+ cleanupPendingRequests();
+ cleanupPendingReporterGrants();
+ cleanupPlaybackDiagnostics();
+ }
+
+ private static String normalize(String value) {
+ return value == null ? "" : value;
+ }
+
+ public static boolean denied(RequestResult result) {
+ return result != null && result.status() == RequestResultStatus.DENIED;
+ }
+
+ public static boolean failed(RequestResult result) {
+ return result == null || result.status() != RequestResultStatus.OK;
+ }
+
+ public static void config(String version) {
+ send(VideoPackets.clientConfig(version));
+ }
+
+ private static void handshakeAck(long nonce) {
+ send(VideoPackets.handshakeAck(nonce));
+ }
+
+ public static void request(VideoScreen screen, String path) {
+ request(screen, path, null);
+ }
+
+ public static void request(VideoScreen screen, String path, Consumer callback) {
+ String normalized = VideoUrlNormalizer.normalizeSubmittedUrl(path);
+ if (!VideoScreen.validPlayUrl(normalized)) {
+ localError(callback, VpTranslation.of(
+ "error.videoplayer.play_url_invalid_length",
+ "Video URL must not be empty or exceed %s UTF-8 bytes",
+ VideoScreen.MAX_PLAY_URL_BYTES
+ ));
+ return;
+ }
+ ByteBuf buf = controlled(VideoPacketType.REQUEST, VideoPermissionAction.PLAY, screen.area.name, screen.name, callback);
+ if (buf == null) return;
+ writeString(buf, screen.area.name);
+ writeString(buf, screen.name);
+ writeString(buf, normalized);
+ send(VideoPackets.toByteArray(buf));
+ }
+
+ public static void sync(VideoScreen screen) {
+ sync(screen, null);
+ }
+
+ public static void sync(VideoScreen screen, Consumer callback) {
+ ByteBuf buf = controlled(VideoPacketType.SYNC, VideoPermissionAction.SYNC, screen.area.name, screen.name, callback);
+ if (buf == null) return;
+ writeString(buf, screen.area.name);
+ writeString(buf, screen.name);
+ buf.writeLong(serverGeneration(screen));
+ send(VideoPackets.toByteArray(buf));
+ }
+
+ public static void seek(VideoScreen screen, long progress) {
+ seek(screen, progress, null);
+ }
+
+ public static void seek(VideoScreen screen, long progress, Consumer callback) {
+ ByteBuf buf = controlled(VideoPacketType.SEEK, VideoPermissionAction.SEEK, screen.area.name, screen.name, callback);
+ if (buf == null) return;
+ writeString(buf, screen.area.name);
+ writeString(buf, screen.name);
+ buf.writeLong(serverGeneration(screen));
+ buf.writeLong(Math.max(0, progress));
+ send(VideoPackets.toByteArray(buf));
+ }
+
+ public static void createArea(Vector3f p1, Vector3f p2, String name) {
+ createArea(p1, p2, name, null);
+ }
+
+ public static void createArea(Vector3f p1, Vector3f p2, String name, Consumer callback) {
+ ByteBuf buf = controlled(VideoPacketType.CREATE_AREA, VideoPermissionAction.CREATE_AREA, "", "", callback);
+ if (buf == null) return;
+ ByteBufUtils.writeVec3(buf, p1);
+ ByteBufUtils.writeVec3(buf, p2);
+ writeString(buf, name);
+ send(VideoPackets.toByteArray(buf));
+ }
+
+ public static void removeArea(String area) {
+ removeArea(area, null);
+ }
+
+ public static void removeArea(String area, Consumer callback) {
+ ByteBuf buf = controlled(VideoPacketType.REMOVE_AREA, VideoPermissionAction.REMOVE_AREA, area, "", callback);
+ if (buf == null) return;
+ writeString(buf, area);
+ send(VideoPackets.toByteArray(buf));
+ }
+
+ public static void createScreen(VideoScreen screen) {
+ createScreen(screen, null);
+ }
+
+ public static void createScreen(VideoScreen screen, Consumer callback) {
+ ByteBuf buf = controlled(VideoPacketType.CREATE_SCREEN, VideoPermissionAction.CREATE_SCREEN, screen.area.name, "", callback);
+ if (buf == null) return;
+ writeString(buf, screen.area.name);
+ VideoScreen.write(buf, screen);
+ send(VideoPackets.toByteArray(buf));
+ }
+
+ public static void removeScreen(VideoScreen screen) {
+ removeScreen(screen, null);
+ }
+
+ public static void removeScreen(VideoScreen screen, Consumer callback) {
+ ByteBuf buf = controlled(VideoPacketType.REMOVE_SCREEN, VideoPermissionAction.REMOVE_SCREEN, screen.area.name, screen.name, callback);
+ if (buf == null) return;
+ writeString(buf, screen.area.name);
+ writeString(buf, screen.name);
+ send(VideoPackets.toByteArray(buf));
+ }
+
+ public static void skip(VideoScreen screen, boolean force) {
+ skip(screen, force, null);
+ }
+
+ public static void skip(VideoScreen screen, boolean force, Consumer callback) {
+ VideoPermissionAction action = force ? VideoPermissionAction.FORCE_SKIP : VideoPermissionAction.VOTE_SKIP;
+ ByteBuf buf = controlled(VideoPacketType.SKIP, action, screen.area.name, screen.name, callback);
+ if (buf == null) return;
+ writeString(buf, screen.area.name);
+ writeString(buf, screen.name);
+ buf.writeLong(serverGeneration(screen));
+ buf.writeBoolean(force);
+ send(VideoPackets.toByteArray(buf));
+ }
+
+ public static void skipPercent(VideoScreen screen, float percent) {
+ skipPercent(screen, percent, null);
+ }
+
+ public static void skipPercent(VideoScreen screen, float percent, Consumer callback) {
+ ByteBuf buf = controlled(VideoPacketType.SKIP_PERCENT, VideoPermissionAction.SET_SKIP_PERCENT, screen.area.name, screen.name, callback);
+ if (buf == null) return;
+ writeString(buf, screen.area.name);
+ writeString(buf, screen.name);
+ buf.writeFloat(percent);
+ send(VideoPackets.toByteArray(buf));
+ }
+
+ public static void addIdlePlay(VideoScreen screen, String url, int priority, Consumer callback) {
+ String normalized = VideoUrlNormalizer.normalizeSubmittedUrl(url);
+ if (!VideoScreen.validIdlePlayUrl(normalized)
+ || priority < IdlePlayEntry.MIN_PRIORITY || priority > IdlePlayEntry.MAX_PRIORITY) {
+ localError(callback, VpTranslation.of("error.videoplayer.idle_play_url_invalid", "IdlePlay URL or priority is invalid"));
+ return;
+ }
+ mutateIdlePlay(screen, IdlePlayMutation.add(normalized, priority), callback);
+ }
+
+ public static void removeIdlePlay(VideoScreen screen, java.util.UUID entryId, Consumer callback) {
+ mutateIdlePlay(screen, IdlePlayMutation.remove(entryId), callback);
+ }
+
+ public static void setIdlePlayPriority(VideoScreen screen, java.util.UUID entryId, int priority, Consumer callback) {
+ mutateIdlePlay(screen, IdlePlayMutation.setPriority(entryId, priority), callback);
+ }
+
+ public static void adjustIdlePlayPriority(VideoScreen screen, java.util.UUID entryId, int delta, Consumer callback) {
+ mutateIdlePlay(screen, IdlePlayMutation.adjustPriority(entryId, delta), callback);
+ }
+
+ public static void clearIdlePlay(VideoScreen screen, Consumer callback) {
+ mutateIdlePlay(screen, IdlePlayMutation.clear(), callback);
+ }
+
+ public static void setIdlePlayMode(VideoScreen screen, boolean random, Consumer callback) {
+ mutateIdlePlay(screen, IdlePlayMutation.setMode(random), callback);
+ }
+
+ private static void mutateIdlePlay(VideoScreen screen, IdlePlayMutation mutation, Consumer callback) {
+ if (screen == null || screen.area == null) return;
+ ByteBuf buf = controlled(VideoPacketType.IDLE_PLAY, VideoPermissionAction.SET_IDLE_PLAY, screen.area.name, screen.name, callback);
+ if (buf == null) return;
+ writeString(buf, screen.area.name);
+ writeString(buf, screen.name);
+ VideoPackets.writeIdlePlayMutation(buf, mutation);
+ send(VideoPackets.toByteArray(buf));
+ }
+
+ private static void localError(Consumer callback, VpTranslation message) {
+ if (callback != null) callback.accept(new RequestResult(0, RequestResultStatus.ERROR, message));
+ }
+
+ public static void setUV(VideoScreen screen, float u1, float v1, float u2, float v2) {
+ setUV(screen, u1, v1, u2, v2, null);
+ }
+
+ public static void setUV(VideoScreen screen, float u1, float v1, float u2, float v2, Consumer callback) {
+ ByteBuf buf = controlled(VideoPacketType.SET_UV, VideoPermissionAction.SET_UV, screen.area.name, screen.name, callback);
+ if (buf == null) return;
+ writeString(buf, screen.area.name);
+ writeString(buf, screen.name);
+ buf.writeFloat(u1);
+ buf.writeFloat(v1);
+ buf.writeFloat(u2);
+ buf.writeFloat(v2);
+ send(VideoPackets.toByteArray(buf));
+ }
+
+ public static void openMenu(VideoScreen screen) {
+ openMenu(screen, null);
+ }
+
+ public static void openMenu(VideoScreen screen, Consumer callback) {
+ ByteBuf buf = controlled(VideoPacketType.OPEN_MENU, VideoPermissionAction.OPEN_MENU, screen.area.name, screen.name, callback);
+ if (buf == null) return;
+ writeString(buf, screen.area.name);
+ writeString(buf, screen.name);
+ send(VideoPackets.toByteArray(buf));
+ }
+
+ public static boolean requestDiagnostics(VideoScreen screen) {
+ return requestDiagnostics(screen, null);
+ }
+
+ public static boolean requestDiagnostics(VideoScreen screen, Consumer callback) {
+ if (screen == null || screen.area == null) return false;
+ ByteBuf buf = controlled(VideoPacketType.DIAGNOSTICS_REQUEST, VideoPermissionAction.OPEN_MENU,
+ screen.area.name, screen.name, callback);
+ if (buf == null) return false;
+ writeString(buf, screen.area.name);
+ writeString(buf, screen.name);
+ send(VideoPackets.toByteArray(buf));
+ return true;
+ }
+
+ public static PlaybackDiagnostics diagnostics(VideoScreen screen) {
+ if (screen == null || screen.area == null) return null;
+ cleanupPlaybackDiagnostics();
+ TimedDiagnostics stored = playbackDiagnostics.get(new DiagnosticsKey(
+ normalize(screen.area.name), normalize(screen.name)
+ ));
+ return stored == null ? null : stored.snapshot();
+ }
+
+ public static void setMetadata(VideoScreen screen, String key, MetaValue value) {
+ setMetadata(screen, key, value, null);
+ }
+
+ public static void setMetadata(VideoScreen screen, String key, MetaValue value, Consumer callback) {
+ ByteBuf buf = controlled(VideoPacketType.SET_SCREEN_METADATA, VideoPermissionAction.SET_METADATA, screen.area.name, screen.name, callback);
+ if (buf == null) return;
+ writeString(buf, screen.area.name);
+ writeString(buf, screen.name);
+ ByteBufUtils.writeString(buf, key);
+ buf.writeBoolean(false);
+ VideoPackets.writeMetaValue(buf, value);
+ send(VideoPackets.toByteArray(buf));
+ }
+
+ public static void removeMetadata(VideoScreen screen, String key) {
+ removeMetadata(screen, key, null);
+ }
+
+ public static void removeMetadata(VideoScreen screen, String key, Consumer callback) {
+ ByteBuf buf = controlled(VideoPacketType.SET_SCREEN_METADATA, VideoPermissionAction.SET_METADATA, screen.area.name, screen.name, callback);
+ if (buf == null) return;
+ writeString(buf, screen.area.name);
+ writeString(buf, screen.name);
+ ByteBufUtils.writeString(buf, key);
+ buf.writeBoolean(true);
+ send(VideoPackets.toByteArray(buf));
+ }
+
+ public static void setScale(VideoScreen screen, boolean fill, float scaleX, float scaleY) {
+ setScale(screen, fill, scaleX, scaleY, null);
+ }
+
+ public static void setScale(VideoScreen screen, boolean fill, float scaleX, float scaleY, Consumer callback) {
+ ByteBuf buf = controlled(VideoPacketType.SET_SCALE, VideoPermissionAction.SET_SCALE, screen.area.name, screen.name, callback);
+ if (buf == null) return;
+ writeString(buf, screen.area.name);
+ writeString(buf, screen.name);
+ buf.writeBoolean(fill);
+ buf.writeFloat(scaleX);
+ buf.writeFloat(scaleY);
+ send(VideoPackets.toByteArray(buf));
+ }
+
+ public static void updateScreen(VideoScreen screen, List vertices, String source) {
+ updateScreen(screen, vertices, source, screen, null);
+ }
+
+ public static void updateScreen(VideoScreen screen, List vertices, String source, Consumer callback) {
+ updateScreen(screen, vertices, source, screen, callback);
+ }
+
+ public static void updateScreen(VideoScreen screen, List vertices, String source, VideoScreen displayConfig) {
+ updateScreen(screen, vertices, source, displayConfig, null);
+ }
+
+ public static void updateScreen(VideoScreen screen, List vertices, String source, VideoScreen displayConfig, Consumer callback) {
+ ByteBuf buf = controlled(VideoPacketType.UPDATE_SCREEN, VideoPermissionAction.UPDATE_SCREEN, screen.area.name, screen.name, callback);
+ if (buf == null) return;
+ writeString(buf, screen.area.name);
+ writeString(buf, screen.name);
+ buf.writeByte(vertices.size());
+ for (Vector3f vertex : vertices) {
+ ByteBufUtils.writeVec3(buf, vertex);
+ }
+ writeString(buf, source == null ? "" : source);
+ VideoScreen.writeDisplayConfig(buf, displayConfig == null ? screen : displayConfig);
+ send(VideoPackets.toByteArray(buf));
+ }
+
+ public static void autoSync(VideoScreen screen, long clientTime, Consumer callback) {
+ ByteBuf buf = controlled(VideoPacketType.AUTO_SYNC, VideoPermissionAction.AUTO_SYNC, screen.area.name, screen.name, callback);
+ if (buf == null) return;
+ writeString(buf, screen.area.name);
+ writeString(buf, screen.name);
+ buf.writeLong(serverGeneration(screen));
+ buf.writeLong(clientTime);
+ send(VideoPackets.toByteArray(buf));
+ }
+
+ public record RequestResult(int requestId, RequestResultStatus status, VpTranslation message) {
+ }
+
+ private record PendingRequest(VideoPermissionAction action, String areaName, String screenName,
+ Consumer callback, long createdAt) {
+ }
+
+ private record ReporterGrantKey(String areaName, String screenName, long generation) {
+ }
+
+ private record PendingReporterGrant(long token, long createdAt) {
+ }
+
+ private record DiagnosticsKey(String areaName, String screenName) {
+ }
+
+ private record TimedDiagnostics(PlaybackDiagnostics snapshot, long receivedAt) {
+ }
+
+ private static ClientVideoArea areaOrNull(String areaName) {
+ ClientVideoArea area = areas.get(areaName);
+ if (area == null) {
+ LOGGER.warn("Unknown video area: {}", areaName);
+ }
+ return area;
+ }
+
+ private static ClientVideoScreen screenOrNull(String areaName, String screenName) {
+ ClientVideoArea area = areas.get(areaName);
+ if (area == null) {
+ LOGGER.warn("Unknown video area: {}", areaName);
+ return null;
+ }
+ ClientVideoScreen screen = area.getScreen(screenName);
+ if (screen == null) {
+ LOGGER.warn("Unknown video screen: {} in {}", screenName, areaName);
+ }
+ return screen;
+ }
+}
diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/ClientYtDlpInstaller.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/ClientYtDlpInstaller.java
new file mode 100644
index 0000000..86e0fc9
--- /dev/null
+++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/ClientYtDlpInstaller.java
@@ -0,0 +1,87 @@
+package com.github.squi2rel.vp;
+
+import net.minecraft.client.Minecraft;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.atomic.AtomicReference;
+
+public final class ClientYtDlpInstaller {
+ public enum State {
+ IDLE,
+ CHECKING,
+ INSTALLING,
+ AVAILABLE,
+ FAILED
+ }
+
+ private static final AtomicReference> IN_FLIGHT = new AtomicReference<>();
+ private static volatile State state = State.IDLE;
+ private static volatile YtDlpManager.EnsureResult lastResult;
+
+ private ClientYtDlpInstaller() {
+ }
+
+ public static CompletableFuture ensureAsync() {
+ return ensureStarted();
+ }
+
+ public static YtDlpManager.EnsureResult ensureBlocking() {
+ return ensureStarted().join();
+ }
+
+ private static CompletableFuture ensureStarted() {
+ while (true) {
+ CompletableFuture current = IN_FLIGHT.get();
+ if (current != null) return current;
+ CompletableFuture created = new CompletableFuture<>();
+ if (!IN_FLIGHT.compareAndSet(null, created)) continue;
+ state = State.CHECKING;
+ CompletableFuture.supplyAsync(ClientYtDlpInstaller::install)
+ .whenComplete((result, error) -> {
+ if (error != null) created.completeExceptionally(error);
+ else created.complete(result);
+ });
+ created.whenComplete((result, error) -> {
+ IN_FLIGHT.compareAndSet(created, null);
+ if (error != null) {
+ state = State.FAILED;
+ VideoPlayerMain.LOGGER.warn("Automatic yt-dlp installation failed", error);
+ return;
+ }
+ publish(result);
+ Minecraft client = Minecraft.getInstance();
+ if (client != null) client.execute(VideoPlayerClient::applyNativePlatformConfig);
+ });
+ return created;
+ }
+ }
+
+ private static YtDlpManager.EnsureResult install() {
+ Config config = VideoPlayerClient.config;
+ String configured = config == null ? "" : config.mpvYtdlPath;
+ String proxy = config == null ? "" : config.nativeDownloadProxy;
+ NativeDownloadConfig downloads = VideoPlayerClient.nativeDownloadConfig();
+ state = State.INSTALLING;
+ YtDlpManager.EnsureResult result = YtDlpManager.ensureAvailable(
+ configured,
+ downloads,
+ NativeDownloadConfig.platformKey(),
+ proxy,
+ null
+ );
+ return result;
+ }
+
+ private static void publish(YtDlpManager.EnsureResult result) {
+ lastResult = result;
+ state = result != null && result.available() ? State.AVAILABLE : State.FAILED;
+ }
+
+ public static State state() {
+ return state;
+ }
+
+ public static YtDlpManager.EnsureResult lastResult() {
+ return lastResult;
+ }
+}
diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/ScreenRenderer.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/ScreenRenderer.java
new file mode 100644
index 0000000..c6225a8
--- /dev/null
+++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/ScreenRenderer.java
@@ -0,0 +1,259 @@
+package com.github.squi2rel.vp;
+
+import com.github.squi2rel.vp.creation.SelectionPreviewRenderer;
+import com.github.squi2rel.vp.danmaku.ClientDanmakuRenderer;
+import com.github.squi2rel.vp.mixin.client.DrawContextAccessor;
+import com.github.squi2rel.vp.render.ExternalTextureRegistry;
+import com.github.squi2rel.vp.render.FrameRenderSnapshot;
+import com.github.squi2rel.vp.render.WorldRenderBatch;
+import com.github.squi2rel.vp.video.ClientVideoScreen;
+import com.github.squi2rel.vp.video.ExternalGlTexture;
+import com.github.squi2rel.vp.vivecraft.Vivecraft;
+import com.mojang.blaze3d.vertex.PoseStack;
+import com.mojang.blaze3d.vertex.VertexConsumer;
+import java.util.ArrayList;
+import java.util.List;
+import net.fabricmc.fabric.api.client.rendering.v1.level.LevelExtractionContext;
+import net.fabricmc.fabric.api.client.rendering.v1.level.LevelRenderContext;
+import net.minecraft.client.Camera;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.GuiGraphicsExtractor;
+import net.minecraft.client.gui.navigation.ScreenRectangle;
+import net.minecraft.client.gui.render.TextureSetup;
+import net.minecraft.client.renderer.RenderPipelines;
+import net.minecraft.client.renderer.rendertype.RenderType;
+import net.minecraft.client.renderer.rendertype.RenderTypes;
+import net.minecraft.client.renderer.state.gui.GuiElementRenderState;
+import net.minecraft.client.renderer.texture.AbstractTexture;
+import net.minecraft.client.renderer.texture.OverlayTexture;
+import net.minecraft.resources.Identifier;
+import net.minecraft.world.phys.Vec3;
+import org.joml.Matrix3x2f;
+import org.joml.Matrix4f;
+import org.joml.Quaternionf;
+import org.joml.Vector2f;
+import org.joml.Vector3f;
+
+import static com.github.squi2rel.vp.VideoPlayerClient.screens;
+
+@SuppressWarnings({"resource", "DataFlowIssue"})
+public final class ScreenRenderer {
+ private static final Identifier PLACEHOLDER_TEXTURE = Identifier.fromNamespaceAndPath("videoplayer", "placeholder.png");
+ private static final ExternalTextureRegistry EXTERNAL_TEXTURES = new ExternalTextureRegistry();
+ private static final Quaternionf rotation = new Quaternionf();
+ private static volatile FrameRenderSnapshot frameSnapshot = FrameRenderSnapshot.EMPTY;
+
+ public static float cameraX;
+ public static float cameraY;
+ public static float cameraZ;
+ public static double preciseCameraX;
+ public static double preciseCameraY;
+ public static double preciseCameraZ;
+ public static boolean skybox;
+
+ private ScreenRenderer() {
+ }
+
+ public static void extract(LevelExtractionContext context) {
+ if (CameraRenderer.isRendering()) {
+ frameSnapshot = FrameRenderSnapshot.EMPTY;
+ return;
+ }
+ Camera cameraObject = context.camera();
+ Vec3 camera = cameraObject.position();
+ preciseCameraX = camera.x;
+ preciseCameraY = camera.y;
+ preciseCameraZ = camera.z;
+ cameraX = (float) camera.x;
+ cameraY = (float) camera.y;
+ cameraZ = (float) camera.z;
+ skybox = false;
+ if (Vivecraft.loaded && Vivecraft.isVRActive()) {
+ rotation.setFromNormalized(Vivecraft.getRotation()).invert();
+ } else {
+ cameraObject.rotation().invert(rotation);
+ }
+
+ WorldRenderBatch batch = new WorldRenderBatch();
+ PoseStack matrices = new PoseStack();
+ List currentScreens = new ArrayList<>(screens);
+ ClientDanmakuRenderer.beginFrame(currentScreens);
+ for (ClientVideoScreen screen : currentScreens) {
+ try {
+ screen.draw(matrices, batch);
+ } catch (RuntimeException error) {
+ VideoPlayerMain.LOGGER.error("Exception while extracting video screen render state", error);
+ }
+ }
+ SelectionPreviewRenderer.extractWorld(batch, cameraObject);
+ frameSnapshot = batch.snapshot();
+ }
+
+ public static void submit(LevelRenderContext context) {
+ if (CameraRenderer.isRendering()) return;
+ frameSnapshot.submit(context.submitNodeCollector());
+ }
+
+ public static RenderType getLayer(int textureId) {
+ return getLayer(textureIdentifier(textureId));
+ }
+
+ public static RenderType getLayer(Identifier texture) {
+ return RenderTypes.entityTranslucent(texture);
+ }
+
+ public static RenderType getTranslucentLayer(int textureId) {
+ return getLayer(textureId);
+ }
+
+ public static RenderType getTranslucentLayer(Identifier texture) {
+ return getLayer(texture);
+ }
+
+ public static RenderType getPremultipliedTranslucentLayer(Identifier texture) {
+ return getLayer(texture);
+ }
+
+ public static RenderType getBackingLayer(int textureId) {
+ return getLayer(textureId);
+ }
+
+ public static Identifier textureIdentifier(int textureId) {
+ if (textureId < 0) return PLACEHOLDER_TEXTURE;
+ ExternalTextureRegistry.Acquisition acquisition = EXTERNAL_TEXTURES.acquire(textureId);
+ Identifier identifier = textureIdentifier(acquisition.registration());
+ if (acquisition.created()) {
+ Minecraft.getInstance().getTextureManager().register(identifier, new ExternalGlTexture(textureId, 1, 1));
+ }
+ return identifier;
+ }
+
+ public static void releaseTexture(int textureId) {
+ if (textureId < 0) return;
+ frameSnapshot = FrameRenderSnapshot.EMPTY;
+ EXTERNAL_TEXTURES.release(textureId).ifPresent(ScreenRenderer::releaseTexture);
+ }
+
+ public static void clearExternalTextures() {
+ frameSnapshot = FrameRenderSnapshot.EMPTY;
+ List registrations = EXTERNAL_TEXTURES.clear();
+ runOnClientThread(() -> {
+ for (ExternalTextureRegistry.Registration registration : registrations) {
+ Minecraft.getInstance().getTextureManager().release(textureIdentifier(registration));
+ }
+ });
+ }
+
+ private static void releaseTexture(ExternalTextureRegistry.Registration registration) {
+ Identifier identifier = textureIdentifier(registration);
+ runOnClientThread(() -> Minecraft.getInstance().getTextureManager().release(identifier));
+ }
+
+ private static Identifier textureIdentifier(ExternalTextureRegistry.Registration registration) {
+ return Identifier.fromNamespaceAndPath("videoplayer", registration.identifierPath());
+ }
+
+ private static void runOnClientThread(Runnable task) {
+ Minecraft client = Minecraft.getInstance();
+ if (client.isSameThread()) {
+ task.run();
+ } else {
+ client.execute(task);
+ }
+ }
+
+ public static int placeholderTextureId() {
+ AbstractTexture texture = Minecraft.getInstance().getTextureManager().getTexture(PLACEHOLDER_TEXTURE);
+ if (texture.getTexture() instanceof com.mojang.blaze3d.opengl.GlTexture glTexture) {
+ return glTexture.glId();
+ }
+ return -1;
+ }
+
+ public static void rotateMatrix(PoseStack matrices) {
+ matrices.mulPose(rotation);
+ }
+
+ public static void drawWorldTexturedVertex(Matrix4f matrix, VertexConsumer consumer, Vector3f vertex,
+ float u, float v, int color, Vector3f normal) {
+ Vector3f safeNormal = normal == null ? new Vector3f(0.0f, 1.0f, 0.0f) : normal;
+ consumer.addVertex(matrix, vertex.x, vertex.y, vertex.z)
+ .setColor(color)
+ .setUv(u, v)
+ .setOverlay(OverlayTexture.NO_OVERLAY)
+ .setLight(0x00F000F0)
+ .setNormal(safeNormal.x, safeNormal.y, safeNormal.z);
+ }
+
+ public static void drawWorldTexturedVertex(Matrix4f matrix, VertexConsumer consumer,
+ float x, float y, float z, float u, float v, int color,
+ float nx, float ny, float nz) {
+ drawWorldTexturedVertex(matrix, consumer, new Vector3f(x, y, z), u, v, color, new Vector3f(nx, ny, nz));
+ }
+
+ public static void drawGuiTexturedTriangles(GuiGraphicsExtractor context, int textureId, List vertices) {
+ if (vertices == null || vertices.size() < 3) return;
+ AbstractTexture texture = Minecraft.getInstance().getTextureManager().getTexture(textureIdentifier(textureId));
+ Matrix3x2f pose = new Matrix3x2f(context.pose());
+ int vertexCount = vertices.size() - vertices.size() % 3;
+ List copiedVertices = List.copyOf(vertices.subList(0, vertexCount));
+ ((DrawContextAccessor) context).videoplayer$getState().addGuiElement(new GuiTexturedTrianglesRenderState(
+ TextureSetup.singleTexture(texture.getTextureView(), texture.getSampler()),
+ pose,
+ copiedVertices,
+ bounds(copiedVertices, pose)
+ ));
+ }
+
+ private static ScreenRectangle bounds(List vertices, Matrix3x2f pose) {
+ Vector2f transformed = new Vector2f();
+ float minX = Float.POSITIVE_INFINITY;
+ float minY = Float.POSITIVE_INFINITY;
+ float maxX = Float.NEGATIVE_INFINITY;
+ float maxY = Float.NEGATIVE_INFINITY;
+ for (GuiVertex vertex : vertices) {
+ pose.transformPosition(vertex.x, vertex.y, transformed);
+ minX = Math.min(minX, transformed.x);
+ minY = Math.min(minY, transformed.y);
+ maxX = Math.max(maxX, transformed.x);
+ maxY = Math.max(maxY, transformed.y);
+ }
+ int x = (int) Math.floor(minX);
+ int y = (int) Math.floor(minY);
+ return new ScreenRectangle(x, y, Math.max(1, (int) Math.ceil(maxX) - x), Math.max(1, (int) Math.ceil(maxY) - y));
+ }
+
+ public record GuiVertex(float x, float y, float u, float v, int color) {
+ }
+
+ private record GuiTexturedTrianglesRenderState(TextureSetup textureSetup, Matrix3x2f pose,
+ List vertices, ScreenRectangle bounds)
+ implements GuiElementRenderState {
+ @Override
+ public void buildVertices(VertexConsumer consumer) {
+ for (int i = 0; i + 2 < vertices.size(); i += 3) {
+ GuiVertex first = vertices.get(i);
+ GuiVertex second = vertices.get(i + 1);
+ GuiVertex third = vertices.get(i + 2);
+ setupVertex(consumer, pose, first);
+ setupVertex(consumer, pose, second);
+ setupVertex(consumer, pose, third);
+ setupVertex(consumer, pose, third);
+ }
+ }
+
+ @Override
+ public com.mojang.blaze3d.pipeline.RenderPipeline pipeline() {
+ return RenderPipelines.GUI_TEXTURED;
+ }
+
+ @Override
+ public ScreenRectangle scissorArea() {
+ return null;
+ }
+ }
+
+ private static void setupVertex(VertexConsumer consumer, Matrix3x2f pose, GuiVertex vertex) {
+ consumer.addVertexWith2DPose(pose, vertex.x, vertex.y).setUv(vertex.u, vertex.v).setColor(vertex.color);
+ }
+}
diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/VideoPlayerClient.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/VideoPlayerClient.java
new file mode 100644
index 0000000..ca11a77
--- /dev/null
+++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/VideoPlayerClient.java
@@ -0,0 +1,1424 @@
+package com.github.squi2rel.vp;
+
+import com.github.squi2rel.vp.network.VideoPayload;
+import com.github.squi2rel.vp.network.VideoProtocol;
+import com.github.squi2rel.vp.provider.VideoInfo;
+import com.github.squi2rel.vp.provider.VideoProviders;
+import com.github.squi2rel.vp.provider.YouTubeProvider;
+import com.github.squi2rel.vp.provider.bilibili.BiliBiliProvider;
+import com.github.squi2rel.vp.provider.bilibili.BiliQuality;
+import com.github.squi2rel.vp.provider.youtube.YouTubeQuality;
+import com.github.squi2rel.vp.creation.StartupGuideScreen;
+import com.github.squi2rel.vp.creation.VideoCreationEditor;
+import com.github.squi2rel.vp.creation.BiliLoginScreen;
+import com.github.squi2rel.vp.creation.ServerStateScreen;
+import com.github.squi2rel.vp.creation.VideoManagementScreen;
+import com.github.squi2rel.vp.creation.YouTubeAuthScreen;
+import com.github.squi2rel.vp.danmaku.BiliAuthRefresher;
+import com.github.squi2rel.vp.danmaku.BiliCookie;
+import com.github.squi2rel.vp.danmaku.ClientDanmakuController;
+import com.github.squi2rel.vp.danmaku.ClientDanmakuRenderer;
+import com.github.squi2rel.vp.command.VideoPlayerCommandHelp;
+import com.github.squi2rel.vp.i18n.VpTexts;
+import com.github.squi2rel.vp.video.*;
+import com.github.squi2rel.vp.vivecraft.Vivecraft;
+import com.google.gson.Gson;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import com.mojang.brigadier.arguments.*;
+import com.mojang.brigadier.builder.LiteralArgumentBuilder;
+import com.mojang.brigadier.context.CommandContext;
+import com.mojang.brigadier.tree.LiteralCommandNode;
+import com.mojang.brigadier.suggestion.SuggestionProvider;
+import com.mojang.brigadier.suggestion.Suggestions;
+import io.netty.buffer.ByteBuf;
+import io.netty.buffer.Unpooled;
+import net.fabricmc.api.ClientModInitializer;
+import net.fabricmc.fabric.api.client.command.v2.ClientCommands;
+import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback;
+import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource;
+import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents;
+import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
+import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents;
+import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
+import net.fabricmc.fabric.api.client.rendering.v1.level.LevelExtractionEvents;
+import net.fabricmc.fabric.api.client.rendering.v1.level.LevelRenderEvents;
+import net.fabricmc.fabric.api.resource.v1.ResourceLoader;
+import net.fabricmc.fabric.api.resource.v1.reloader.ResourceReloaderKeys;
+import net.fabricmc.fabric.api.resource.v1.reloader.SimpleReloadListener;
+import net.fabricmc.loader.api.FabricLoader;
+import net.minecraft.ChatFormatting;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.components.LerpingBossEvent;
+import net.minecraft.client.multiplayer.ClientPacketListener;
+import net.minecraft.core.component.DataComponents;
+import net.minecraft.core.registries.BuiltInRegistries;
+import net.minecraft.network.chat.Component;
+import net.minecraft.network.protocol.game.ClientboundBossEventPacket;
+import net.minecraft.resources.Identifier;
+import net.minecraft.server.packs.PackType;
+import net.minecraft.server.packs.resources.PreparableReloadListener;
+import net.minecraft.util.profiling.Profiler;
+import net.minecraft.util.profiling.ProfilerFiller;
+import net.minecraft.world.BossEvent;
+import net.minecraft.world.InteractionHand;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.item.component.CustomModelData;
+import net.minecraft.world.phys.Vec3;
+import org.joml.Vector3d;
+import org.joml.Vector3f;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.AtomicMoveNotSupportedException;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.StandardOpenOption;
+import java.util.*;
+import java.util.stream.Collectors;
+
+import static com.github.squi2rel.vp.VideoPlayerMain.LOGGER;
+import static com.github.squi2rel.vp.VideoPlayerMain.error;
+
+
+@SuppressWarnings({"DataFlowIssue"})
+public class VideoPlayerClient implements ClientModInitializer {
+ private static final long HANDSHAKE_TIMEOUT_MS = 10_000L;
+ public static final Path configPath = FabricLoader.getInstance().getConfigDir().resolve("videoplayer").resolve("videoplayer-client.json");
+ private static final Path startupGuideVersionPath = configPath.getParent().resolve("startup-guide-version.txt");
+ public static final Minecraft client = Minecraft.getInstance();
+ private static final VideoConnectionDiagnostics connectionDiagnostics = new VideoConnectionDiagnostics(
+ HANDSHAKE_TIMEOUT_MS,
+ System::currentTimeMillis,
+ VideoPlayerClient::logConnectionEvent
+ );
+ public static Config config;
+ private static final Gson gson = new Gson();
+ private static volatile AudioChannelMode activeAudioChannelMode = AudioChannelMode.STEREO;
+
+ public static final HashMap areas = new HashMap<>();
+ public static final ArrayList screens = new ArrayList<>();
+ private static final TouchHandler touchHandler = new TouchHandler();
+ private static ClientVideoScreen currentLooking, currentScreen;
+ private static boolean isInArea = false;
+ private static final BossEvent bossBar = new LerpingBossEvent(UUID.randomUUID(), Component.nullToEmpty(""), 0, BossEvent.BossBarColor.WHITE, BossEvent.BossBarOverlay.PROGRESS, false, false, false);
+ private static boolean bossBarAdded = false;
+ private static boolean keyPressed = false;
+ private static boolean startupGuideOpened = false;
+ private static boolean pendingStartupGuideScreen = false;
+ private static boolean pendingBiliLoginScreen = false;
+ private static boolean pendingYouTubeAuthScreen = false;
+ private static boolean joinHandshakePending;
+ private static boolean protocolRejected;
+ private static boolean protocolMismatchShown;
+ private static long handshakeNonce;
+
+ public static boolean connected = false;
+ public static String remoteControlName = "minecraft:iron_ingot";
+ public static float remoteControlId = -1;
+ public static float remoteControlRange = 64;
+ public static float noControlRange = 16;
+ public static boolean remoteControl = false;
+
+ public static boolean updated = false;
+ public static Runnable disconnectHandler = () -> {};
+
+ private static final SuggestionProvider SUGGEST_AREAS = (context, builder) -> {
+ for (ClientVideoArea a : areas.values()) {
+ if (a.name.startsWith(builder.getRemaining())) {
+ builder.suggest("\"" + a.name.replace("\\", "\\\\") + "\"");
+ }
+ }
+ return builder.buildFuture();
+ };
+
+ private static final SuggestionProvider SUGGEST_SCREENS = (context, builder) -> {
+ ClientVideoArea area = areas.get(context.getArgument("area", String.class));
+ if (area == null) return Suggestions.empty();
+ for (VideoScreen screen : area.screens) {
+ if (!((ClientVideoScreen) screen).interactable) continue;
+ if (screen.name.startsWith(builder.getRemaining())) {
+ builder.suggest("\"" + screen.name.replace("\\", "\\\\") + "\"");
+ }
+ }
+ return builder.buildFuture();
+ };
+
+ private static final SuggestionProvider SUGGEST_REAL_SCREENS = (context, builder) -> {
+ ClientVideoArea area = areas.get(context.getArgument("area", String.class));
+ if (area == null) return Suggestions.empty();
+ for (VideoScreen screen : area.screens) {
+ if (!screen.source.isEmpty() || !((ClientVideoScreen) screen).interactable) continue;
+ if (screen.name.startsWith(builder.getRemaining())) {
+ builder.suggest("\"" + screen.name.replace("\\", "\\\\") + "\"");
+ }
+ }
+ return builder.buildFuture();
+ };
+
+ @Override
+ public void onInitializeClient() {
+ if (error != null) {
+ ClientPlayConnectionEvents.JOIN.register((h, s, c) -> c.player.sendSystemMessage(VpTexts.tr(
+ "message.videoplayer.backend_load_failed",
+ "VideoPlayer error: video backend failed to load\n%s\nSee logs for more information",
+ error
+ ).withStyle(ChatFormatting.RED)));
+ }
+ loadConfig();
+ registerExternalTextureReload();
+ activeAudioChannelMode = AudioChannelMode.normalize(config.audioChannelMode);
+ BiliBiliProvider.setCookieSupplier(BiliCookie::header);
+ YouTubeProvider.configureMissingYtdlHandler(() -> {
+ YtDlpManager.EnsureResult result = ClientYtDlpInstaller.ensureBlocking();
+ return result == null || result.detection() == null ? "" : result.detection().executable();
+ });
+ BiliAuthRefresher.checkOnStartup();
+ ClientTickEvents.END_CLIENT_TICK.register(client -> {
+ BiliAuthRefresher.tick();
+ tickHandshake(client);
+ });
+ registerStartupGuide();
+ registerStartupGuideScreenOpener();
+ registerBiliLoginScreenOpener();
+ registerYouTubeAuthScreenOpener();
+ VideoProviders.register();
+ if (!VideoPlayerMain.android) ClientYtDlpInstaller.ensureAsync();
+ disconnectHandler = () -> client.execute(VideoPlayerClient::cleanupClientState);
+ ClientLifecycleEvents.CLIENT_STOPPING.register(ignored -> {
+ cleanupClientState();
+ connectionDiagnostics.disconnected();
+ });
+ if (Vivecraft.loaded) LOGGER.info("Found Vivecraft");
+ ClientPlayConnectionEvents.JOIN.register((h, s, c) -> {
+ joinHandshakePending = true;
+ handshakeNonce = 0L;
+ connected = false;
+ protocolRejected = false;
+ protocolMismatchShown = false;
+ connectionDiagnostics.beginJoin(currentServerAddress(), VideoPlayerMain.version);
+ });
+ ClientPlayConnectionEvents.DISCONNECT.register((h, c) -> connectionDiagnostics.disconnected());
+ LevelExtractionEvents.END_EXTRACTION.register(ScreenRenderer::extract);
+ LevelRenderEvents.COLLECT_SUBMITS.register(ScreenRenderer::submit);
+ VideoCreationEditor.register();
+ ClientPlayNetworking.registerGlobalReceiver(VideoPayload.ID, (p, c) -> {
+ long receivedAt = System.currentTimeMillis();
+ client.execute(() -> {
+ ByteBuf buf = Unpooled.wrappedBuffer(p.data());
+ try {
+ ClientPacketHandler.handle(buf, receivedAt);
+ } catch (Exception e) {
+ LOGGER.error("Exception while handling packet", e);
+ } finally {
+ buf.release();
+ }
+ });
+ });
+ ClientCommandRegistrationCallback.EVENT.register((d, c) -> {
+ LiteralCommandNode videoplayerRoot = d.register(ClientCommands.literal("videoplayer")
+ .executes(VideoPlayerClient::showCommandHelp)
+ .then(commandHelp())
+ .then(ClientCommands.literal("play")
+ .then(ClientCommands.argument("url", StringArgumentType.greedyString())
+ .executes(s -> {
+ if (checkInvalid(s, true)) return 0;
+ ClientPacketHandler.request(currentScreen.getScreen(), s.getArgument("url", String.class));
+ return 1;
+ })))
+ .then(ClientCommands.literal("playthat")
+ .then(ClientCommands.argument("area", StringArgumentType.string()).suggests(SUGGEST_AREAS)
+ .then(ClientCommands.argument("screen", StringArgumentType.string()).suggests(SUGGEST_REAL_SCREENS)
+ .then(ClientCommands.argument("url", StringArgumentType.greedyString())
+ .executes(s -> {
+ ClientVideoScreen screen = getScreen(s);
+ if (screen == null) return 0;
+ ClientPacketHandler.request(screen.getScreen(), s.getArgument("url", String.class));
+ return 1;
+ })))))
+ .then(ClientCommands.literal("skip")
+ .then(ClientCommands.argument("force", BoolArgumentType.bool())
+ .executes(s -> {
+ if (checkInvalid(s, true)) return 0;
+ ClientPacketHandler.skip(currentScreen.getScreen(), s.getArgument("force", Boolean.class));
+ return 1;
+ }))
+ .then(ClientCommands.argument("area", StringArgumentType.string()).suggests(SUGGEST_AREAS)
+ .then(ClientCommands.argument("screen", StringArgumentType.string()).suggests(SUGGEST_REAL_SCREENS)
+ .then(ClientCommands.argument("force", BoolArgumentType.bool())
+ .executes(s -> {
+ ClientVideoScreen screen = getScreen(s);
+ if (screen == null) return 0;
+ ClientPacketHandler.skip(screen.getScreen(), s.getArgument("force", Boolean.class));
+ return 1;
+ })
+ )))
+ .executes(s -> {
+ if (checkInvalid(s, true)) return 0;
+ ClientPacketHandler.skip(currentScreen.getScreen(), false);
+ return 1;
+ })
+ )
+ .then(ClientCommands.literal("volume")
+ .then(ClientCommands.argument("volume", IntegerArgumentType.integer(0, 100))
+ .executes(s -> {
+ int v = s.getArgument("volume", Integer.class);
+ config.volume = v;
+ saveConfig();
+ s.getSource().sendFeedback(VpTexts.tr("message.videoplayer.volume_set", "Volume set to %s%%", v).withStyle(ChatFormatting.GREEN));
+ applyConfiguredVolume();
+ return 1;
+ })))
+ .then(ClientCommands.literal("backend")
+ .then(ClientCommands.literal(VideoBackends.VLC)
+ .executes(s -> setVideoBackend(s, VideoBackends.VLC)))
+ .then(ClientCommands.literal(VideoBackends.MPV)
+ .executes(s -> setVideoBackend(s, VideoBackends.MPV)))
+ .executes(s -> {
+ s.getSource().sendFeedback(VpTexts.tr("message.videoplayer.current_backend", "Current playback backend: %s", VideoBackends.normalize(config.videoBackend)).withStyle(ChatFormatting.GREEN));
+ return 1;
+ }))
+ .then(ClientCommands.literal("audio")
+ .then(ClientCommands.literal(AudioChannelMode.STEREO.configValue())
+ .executes(s -> setAudioChannelMode(s, AudioChannelMode.STEREO)))
+ .then(ClientCommands.literal(AudioChannelMode.AUTO.configValue())
+ .executes(s -> setAudioChannelMode(s, AudioChannelMode.AUTO)))
+ .executes(VideoPlayerClient::showAudioChannelMode))
+ .then(ClientCommands.literal("boot")
+ .executes(VideoPlayerClient::openStartupGuide))
+ .then(ClientCommands.literal("diagnostics")
+ .executes(VideoPlayerClient::openDiagnostics))
+ .then(biliAuthCommand("biliAuth"))
+ .then(youtubeAuthCommand("youtubeAuth"))
+ .then(youtubeAuthCommand("youtube-auth"))
+ .then(ClientCommands.literal("danmaku")
+ .executes(s -> {
+ if (checkInvalid(s, false)) return 0;
+ boolean enabled = ClientDanmakuController.toggleGlobal();
+ s.getSource().sendFeedback(VpTexts.tr("message.videoplayer.danmaku_state", "Danmaku: %s",
+ (enabled ? VpTexts.tr("label.videoplayer.on", "On") : VpTexts.tr("label.videoplayer.off", "Off")).getString()
+ ).withStyle(ChatFormatting.GREEN));
+ return 1;
+ }))
+ .then(ClientCommands.literal("createArea")
+ .then(ClientCommands.argument("x1", FloatArgumentType.floatArg())
+ .then(ClientCommands.argument("y1", FloatArgumentType.floatArg())
+ .then(ClientCommands.argument("z1", FloatArgumentType.floatArg())
+ .then(ClientCommands.argument("x2", FloatArgumentType.floatArg())
+ .then(ClientCommands.argument("y2", FloatArgumentType.floatArg())
+ .then(ClientCommands.argument("z2", FloatArgumentType.floatArg())
+ .then(ClientCommands.argument("name", StringArgumentType.string())
+ .executes(s -> {
+ if (checkInvalid(s, false)) return 0;
+ ClientPacketHandler.createArea(
+ new Vector3f(
+ s.getArgument("x1", Float.class),
+ s.getArgument("y1", Float.class),
+ s.getArgument("z1", Float.class)
+ ),
+ new Vector3f(
+ s.getArgument("x2", Float.class),
+ s.getArgument("y2", Float.class),
+ s.getArgument("z2", Float.class)
+ ),
+ s.getArgument("name", String.class)
+ );
+ return 1;
+ })))))))))
+ .then(ClientCommands.literal("removeArea")
+ .then(ClientCommands.argument("name", StringArgumentType.string()).suggests(SUGGEST_AREAS)
+ .executes(s -> {
+ if (checkInvalid(s, false)) return 0;
+ String name = s.getArgument("name", String.class);
+ ClientPacketHandler.removeArea(name);
+ return 1;
+ })))
+ .then(ClientCommands.literal("createScreen")
+ .then(ClientCommands.argument("area", StringArgumentType.string()).suggests(SUGGEST_AREAS)
+ .then(ClientCommands.argument("name", StringArgumentType.string())
+ .then(ClientCommands.argument("x1", FloatArgumentType.floatArg())
+ .then(ClientCommands.argument("y1", FloatArgumentType.floatArg())
+ .then(ClientCommands.argument("z1", FloatArgumentType.floatArg())
+ .then(ClientCommands.argument("x2", FloatArgumentType.floatArg())
+ .then(ClientCommands.argument("y2", FloatArgumentType.floatArg())
+ .then(ClientCommands.argument("z2", FloatArgumentType.floatArg())
+ .then(ClientCommands.argument("x3", FloatArgumentType.floatArg())
+ .then(ClientCommands.argument("y3", FloatArgumentType.floatArg())
+ .then(ClientCommands.argument("z3", FloatArgumentType.floatArg())
+ .then(ClientCommands.argument("x4", FloatArgumentType.floatArg())
+ .then(ClientCommands.argument("y4", FloatArgumentType.floatArg())
+ .then(ClientCommands.argument("z4", FloatArgumentType.floatArg())
+ .then(ClientCommands.argument("source", StringArgumentType.string()).suggests(SUGGEST_REAL_SCREENS)
+ .executes(s -> {
+ ClientVideoArea area = getArea(s);
+ if (area == null) return 0;
+ ClientPacketHandler.createScreen(new VideoScreen(
+ area,
+ s.getArgument("name", String.class),
+ new Vector3f(
+ s.getArgument("x1", Float.class),
+ s.getArgument("y1", Float.class),
+ s.getArgument("z1", Float.class)
+ ),
+ new Vector3f(
+ s.getArgument("x2", Float.class),
+ s.getArgument("y2", Float.class),
+ s.getArgument("z2", Float.class)
+ ),
+ new Vector3f(
+ s.getArgument("x3", Float.class),
+ s.getArgument("y3", Float.class),
+ s.getArgument("z3", Float.class)
+ ),
+ new Vector3f(
+ s.getArgument("x4", Float.class),
+ s.getArgument("y4", Float.class),
+ s.getArgument("z4", Float.class)
+ ),
+ s.getArgument("source", String.class)
+ ));
+ return 1;
+ })))))))))))))))))
+ .then(ClientCommands.literal("removeScreen")
+ .then(ClientCommands.argument("area", StringArgumentType.string()).suggests(SUGGEST_AREAS)
+ .then(ClientCommands.argument("name", StringArgumentType.string()).suggests(SUGGEST_SCREENS)
+ .executes(s -> {
+ ClientVideoArea area = getArea(s);
+ if (area == null) return 0;
+ String screenName = s.getArgument("name", String.class);
+ VideoScreen screen = area.getScreen(screenName);
+ if (screen == null) {
+ s.getSource().sendFeedback(VpTexts.tr("error.videoplayer.screen_named_not_found", "No screen named %s", screenName));
+ return 0;
+ }
+ ClientPacketHandler.removeScreen(screen);
+ return 1;
+ }))))
+ .then(ClientCommands.literal("skipPercent")
+ .then(ClientCommands.argument("percent", FloatArgumentType.floatArg(0, 1.01f))
+ .executes(s -> {
+ if (checkInvalid(s, true)) return 0;
+ ClientPacketHandler.skipPercent(currentScreen, s.getArgument("percent", Float.class));
+ return 1;
+ })))
+ .then(ClientCommands.literal("list")
+ .executes(s -> {
+ if (checkInvalid(s, true)) return 0;
+ String str = currentScreen.getScreen().infos.stream()
+ .map(i -> VpTexts.tr("message.videoplayer.queue_item", "%s requested by: %s", i.name(), i.playerName()).getString())
+ .collect(Collectors.joining("\n"));
+ s.getSource().sendFeedback(VpTexts.tr("message.videoplayer.queue_list", "Video area %s screen %s\n%s",
+ currentScreen.area.name, currentScreen.name,
+ str.isEmpty() ? VpTexts.tr("message.videoplayer.queue_empty", "Queue is empty").getString() : str
+ ).withStyle(ChatFormatting.GOLD));
+ return 1;
+ }))
+ .then(ClientCommands.literal("sync")
+ .executes(s -> {
+ if (checkInvalid(s, true)) return 0;
+ ClientPacketHandler.sync(currentScreen);
+ return 1;
+ }))
+ .then(ClientCommands.literal("brightness")
+ .then(ClientCommands.argument("brightness", IntegerArgumentType.integer(0, 100))
+ .executes(s -> {
+ config.brightness = s.getArgument("brightness", Integer.class);
+ s.getSource().sendFeedback(VpTexts.tr("message.videoplayer.brightness_set", "Brightness set to %s%%", config.brightness).withStyle(ChatFormatting.GREEN));
+ saveConfig();
+ return 1;
+ })))
+ .then(ClientCommands.literal("slice")
+ .then(ClientCommands.argument("u1", FloatArgumentType.floatArg())
+ .then(ClientCommands.argument("v1", FloatArgumentType.floatArg())
+ .then(ClientCommands.argument("u2", FloatArgumentType.floatArg())
+ .then(ClientCommands.argument("v2", FloatArgumentType.floatArg())
+ .executes(s -> {
+ if (checkInvalidLooking(s)) return 0;
+ float u1 = s.getArgument("u1", Float.class);
+ float v1 = s.getArgument("v1", Float.class);
+ float u2 = s.getArgument("u2", Float.class);
+ float v2 = s.getArgument("v2", Float.class);
+ ClientPacketHandler.setUV(currentLooking, u1, v1, u2, v2);
+ return 1;
+ }))))))
+ .then(ClientCommands.literal("stop")
+ .executes(s -> {
+ if (checkInvalid(s, true)) return 0;
+ currentScreen.clearPlaybackState();
+ if (currentScreen.player != null) currentScreen.player.stop();
+ return 1;
+ }))
+ .then(ClientCommands.literal("setmeta")
+ .then(ClientCommands.argument("area", StringArgumentType.string()).suggests(SUGGEST_AREAS)
+ .then(ClientCommands.argument("screen", StringArgumentType.string()).suggests(SUGGEST_SCREENS)
+ .then(ClientCommands.literal("mute")
+ .then(ClientCommands.argument("mute", BoolArgumentType.bool())
+ .executes(s -> {
+ ClientVideoScreen screen = getScreen(s);
+ if (screen == null) return 0;
+ ClientPacketHandler.setMetadata(screen, "mute", MetaValue.ofBool(s.getArgument("mute", Boolean.class)));
+ return 1;
+ })))
+ .then(ClientCommands.literal("interactable")
+ .then(ClientCommands.argument("interactable", BoolArgumentType.bool())
+ .executes(s -> {
+ ClientVideoScreen screen = getScreen(s);
+ if (screen == null) return 0;
+ ClientPacketHandler.setMetadata(screen, "interactable", MetaValue.ofBool(s.getArgument("interactable", Boolean.class)));
+ return 1;
+ })))
+ .then(ClientCommands.literal("autoSync")
+ .then(ClientCommands.argument("autoSync", BoolArgumentType.bool())
+ .executes(s -> {
+ ClientVideoScreen screen = getScreen(s);
+ if (screen == null) return 0;
+ ClientPacketHandler.setMetadata(screen, "autoSync", MetaValue.ofBool(s.getArgument("autoSync", Boolean.class)));
+ return 1;
+ })))
+ .then(ClientCommands.literal("custom")
+ .then(ClientCommands.literal("set")
+ .then(ClientCommands.argument("key", StringArgumentType.string())
+ .then(ClientCommands.argument("value", IntegerArgumentType.integer())
+ .executes(s -> {
+ ClientVideoScreen screen = getScreen(s);
+ if (screen == null) return 0;
+ ClientPacketHandler.setMetadata(screen, s.getArgument("key", String.class), MetaValue.ofInt(s.getArgument("value", Integer.class)));
+ return 1;
+ }))))
+ .then(ClientCommands.literal("get")
+ .then(ClientCommands.argument("key", StringArgumentType.string())
+ .executes(s -> {
+ ClientVideoScreen screen = getScreen(s);
+ if (screen == null) return 0;
+ String key = s.getArgument("key", String.class);
+ MetaValue value = screen.metadata.get(key);
+ s.getSource().sendFeedback(Component.literal(key + "=" + (value == null ? "null" : value.toDisplayString())));
+ return 1;
+ })))
+ .then(ClientCommands.literal("remove")
+ .then(ClientCommands.argument("key", StringArgumentType.string())
+ .executes(s -> {
+ ClientVideoScreen screen = getScreen(s);
+ if (screen == null) return 0;
+ ClientPacketHandler.removeMetadata(screen, s.getArgument("key", String.class));
+ return 1;
+ })))
+ .then(ClientCommands.literal("list")
+ .executes(s -> {
+ ClientVideoScreen screen = getScreen(s);
+ if (screen == null) return 0;
+ s.getSource().sendFeedback(Component.literal(screen.metadata.entries().toString()));
+ return 1;
+ })))
+ )))
+ .then(ClientCommands.literal("scale")
+ .then(ClientCommands.literal("stretch")
+ .executes(s -> {
+ if (checkInvalidLooking(s)) return 0;
+ ClientPacketHandler.setScale(currentLooking, true, 1, 1);
+ return 1;
+ }))
+ .then(ClientCommands.literal("auto")
+ .executes(s -> {
+ if (checkInvalidLooking(s)) return 0;
+ ClientPacketHandler.setScale(currentLooking, false, 1, 1);
+ return 1;
+ }))
+ .then(ClientCommands.literal("set")
+ .then(ClientCommands.argument("scaleX", FloatArgumentType.floatArg(0.0625f, 16f))
+ .then(ClientCommands.argument("scaleY", FloatArgumentType.floatArg(0.0625f, 16f))
+ .executes(s -> {
+ if (checkInvalidLooking(s)) return 0;
+ ClientPacketHandler.setScale(currentLooking, false, s.getArgument("scaleX", Float.class), s.getArgument("scaleY", Float.class));
+ return 1;
+ })))))
+ );
+ d.register(ClientCommands.literal("vlc")
+ .executes(VideoPlayerClient::showCommandHelp)
+ .redirect(videoplayerRoot));
+ });
+ ClientTickEvents.END_CLIENT_TICK.register(client -> {
+ if (client.player == null || client.level == null || client.gui.screen() != null || currentLooking == null) return;
+ boolean pressed = client.options.keyUse.isDown();
+ if (pressed && !keyPressed) {
+ keyPressed = true;
+ if (remoteControl || client.player.getItemInHand(InteractionHand.MAIN_HAND).isEmpty() && client.player.getItemInHand(InteractionHand.OFF_HAND).isEmpty()) {
+ ClientVideoScreen selected = currentLooking;
+ ClientPacketHandler.openMenu(selected, result -> {
+ if (!ClientPacketHandler.failed(result) && client.gui.screen() == null) {
+ VideoCreationEditor.instance().openConfigScreen(selected);
+ }
+ });
+ }
+ } else if (!pressed) {
+ keyPressed = false;
+ }
+ });
+ }
+
+ private static LiteralArgumentBuilder commandHelp() {
+ return ClientCommands.literal("help")
+ .executes(VideoPlayerClient::showCommandHelp)
+ .then(ClientCommands.argument("subcommand", StringArgumentType.word()).suggests((context, builder) -> {
+ for (VideoPlayerCommandHelp.Entry entry : VideoPlayerCommandHelp.entries()) {
+ if (entry.name().toLowerCase(Locale.ROOT).startsWith(builder.getRemaining().toLowerCase(Locale.ROOT))) {
+ builder.suggest(entry.name());
+ }
+ }
+ return builder.buildFuture();
+ }).executes(VideoPlayerClient::showCommandHelp));
+ }
+
+ private static int showCommandHelp(CommandContext context) {
+ String subcommand = null;
+ try {
+ subcommand = context.getArgument("subcommand", String.class);
+ } catch (IllegalArgumentException ignored) {
+ }
+ if (subcommand == null || subcommand.isBlank()) {
+ context.getSource().sendFeedback(VpTexts.tr(
+ "command.videoplayer.help.header",
+ "VideoPlayer client commands. Use /videoplayer help for details."
+ ).withStyle(ChatFormatting.GOLD));
+ for (VideoPlayerCommandHelp.Entry entry : VideoPlayerCommandHelp.entries()) {
+ String detailKey = "command.videoplayer.help." + entry.name().toLowerCase(Locale.ROOT) + ".detail";
+ context.getSource().sendFeedback(VpTexts.tr(
+ "command.videoplayer.help." + entry.name().toLowerCase(Locale.ROOT) + ".summary",
+ "%1$s - %2$s",
+ entry.usage().isBlank() ? "/videoplayer " + entry.name() : "/videoplayer " + entry.name() + " " + entry.usage(),
+ VpTexts.tr(detailKey, entry.details()).getString()
+ ));
+ }
+ context.getSource().sendFeedback(VpTexts.tr(
+ "command.videoplayer.help.alias",
+ "/vlc remains a compatible alias for /videoplayer."
+ ).withStyle(ChatFormatting.GRAY));
+ return 1;
+ }
+ Optional found = VideoPlayerCommandHelp.find(subcommand);
+ if (found.isEmpty()) {
+ context.getSource().sendFeedback(VpTexts.tr(
+ "command.videoplayer.help.unknown",
+ "Unknown subcommand '%s'. Use /videoplayer help to list available commands.",
+ subcommand
+ ).withStyle(ChatFormatting.RED));
+ return 0;
+ }
+ VideoPlayerCommandHelp.Entry entry = found.get();
+ String usage = entry.usage().isBlank()
+ ? "/videoplayer " + entry.name()
+ : "/videoplayer " + entry.name() + " " + entry.usage();
+ context.getSource().sendFeedback(Component.literal(usage).withStyle(ChatFormatting.AQUA));
+ context.getSource().sendFeedback(VpTexts.tr(
+ "command.videoplayer.help." + entry.name().toLowerCase(Locale.ROOT) + ".detail",
+ entry.details()
+ ));
+ return 1;
+ }
+
+ private static LiteralArgumentBuilder biliAuthCommand(String literal) {
+ return ClientCommands.literal(literal)
+ .executes(VideoPlayerClient::showBiliAuthHelp)
+ .then(ClientCommands.literal("login")
+ .executes(VideoPlayerClient::openBiliLogin))
+ .then(ClientCommands.literal("set")
+ .then(ClientCommands.argument("cookie", StringArgumentType.greedyString())
+ .executes(s -> {
+ BiliCookie.set(s.getArgument("cookie", String.class));
+ s.getSource().sendFeedback(VpTexts.tr("message.videoplayer.bilibili_cookie_saved", "Bilibili auth saved locally").withStyle(ChatFormatting.GREEN));
+ return 1;
+ })))
+ .then(ClientCommands.literal("clear")
+ .executes(s -> {
+ BiliCookie.clear();
+ s.getSource().sendFeedback(VpTexts.tr("message.videoplayer.bilibili_cookie_cleared", "Bilibili auth cleared").withStyle(ChatFormatting.GREEN));
+ return 1;
+ }))
+ .then(ClientCommands.literal("status")
+ .executes(s -> {
+ s.getSource().sendFeedback(VpTexts.text(BiliCookie.status()).withStyle(ChatFormatting.GREEN));
+ return 1;
+ }));
+ }
+
+ private static int showBiliAuthHelp(CommandContext context) {
+ VideoPlayerCommandHelp.find("biliAuth").ifPresent(entry -> context.getSource().sendFeedback(VpTexts.tr(
+ "command.videoplayer.help.biliauth.detail", entry.details()
+ )));
+ return 1;
+ }
+
+ private static LiteralArgumentBuilder youtubeAuthCommand(String literal) {
+ return ClientCommands.literal(literal)
+ .executes(VideoPlayerClient::openYouTubeAuth)
+ .then(ClientCommands.literal("login").executes(VideoPlayerClient::openYouTubeAuth))
+ .then(ClientCommands.literal("clear")
+ .executes(s -> {
+ config.youtubeCookiesFile = "";
+ config.youtubeCookiesFromBrowser = "";
+ saveConfig();
+ applyNativePlatformConfig();
+ s.getSource().sendFeedback(VpTexts.tr(
+ "message.videoplayer.youtube_auth_cleared",
+ "YouTube authentication settings cleared"
+ ).withStyle(ChatFormatting.GREEN));
+ return 1;
+ }))
+ .then(ClientCommands.literal("status")
+ .executes(s -> {
+ boolean file = config.youtubeCookiesFile != null && !config.youtubeCookiesFile.isBlank();
+ boolean browser = config.youtubeCookiesFromBrowser != null && !config.youtubeCookiesFromBrowser.isBlank();
+ String configured = VpTexts.tr("label.videoplayer.configured", "Configured").getString();
+ String notConfigured = VpTexts.tr("label.videoplayer.not_configured", "Not configured").getString();
+ s.getSource().sendFeedback(VpTexts.tr(
+ "message.videoplayer.youtube_auth_status",
+ "YouTube authentication: cookie file=%s, browser profile=%s",
+ file ? configured : notConfigured,
+ browser ? configured : notConfigured
+ ).withStyle(ChatFormatting.GREEN));
+ return 1;
+ }));
+ }
+
+ private static int openBiliLogin(CommandContext s) {
+ pendingBiliLoginScreen = true;
+ return 1;
+ }
+
+ private static int openStartupGuide(CommandContext s) {
+ pendingStartupGuideScreen = true;
+ return 1;
+ }
+
+ private static int setVideoBackend(CommandContext s, String backend) {
+ config.videoBackend = VideoBackends.normalize(backend);
+ saveConfig();
+ if (VideoBackends.MPV.equals(config.videoBackend) && !MpvVideoBackend.isAvailable()) {
+ LOGGER.warn("MPV backend selected but libmpv is not available", MpvVideoBackend.loadError());
+ pendingStartupGuideScreen = true;
+ s.getSource().sendFeedback(VpTexts.tr(
+ "message.videoplayer.backend_mpv_unavailable",
+ "MPV is unavailable. The setup guide will open; download the MPV runtime there. New videos use VLC until installation finishes."
+ ).withStyle(ChatFormatting.YELLOW));
+ return 1;
+ }
+ s.getSource().sendFeedback(VpTexts.tr("message.videoplayer.backend_set", "Playback backend set to %s. Only newly started videos are affected.", config.videoBackend).withStyle(ChatFormatting.GREEN));
+ return 1;
+ }
+
+ private static int showAudioChannelMode(CommandContext context) {
+ AudioChannelMode configured = AudioChannelMode.normalize(config.audioChannelMode);
+ boolean restartRequired = configured != activeAudioChannelMode;
+ context.getSource().sendFeedback(VpTexts.tr(
+ restartRequired ? "message.videoplayer.audio_channel_status_restart_required" : "message.videoplayer.audio_channel_status",
+ restartRequired
+ ? "Audio channel mode: configured %s, active %s. Restart Minecraft to apply the configured mode."
+ : "Audio channel mode: configured %s, active %s.",
+ audioChannelModeLabel(configured).getString(),
+ audioChannelModeLabel(activeAudioChannelMode).getString()
+ ).withStyle(restartRequired ? ChatFormatting.YELLOW : ChatFormatting.GREEN));
+ return 1;
+ }
+
+ private static int setAudioChannelMode(CommandContext context, AudioChannelMode mode) {
+ config.audioChannelMode = mode.configValue();
+ saveConfig();
+ boolean restartRequired = mode != activeAudioChannelMode;
+ context.getSource().sendFeedback(VpTexts.tr(
+ restartRequired ? "message.videoplayer.audio_channel_mode_restart_required" : "message.videoplayer.audio_channel_mode_set",
+ restartRequired
+ ? "Audio channel mode saved as %s. Restart Minecraft to apply."
+ : "Audio channel mode saved as %s and is already active.",
+ audioChannelModeLabel(mode).getString()
+ ).withStyle(restartRequired ? ChatFormatting.YELLOW : ChatFormatting.GREEN));
+ return 1;
+ }
+
+ private static Component audioChannelModeLabel(AudioChannelMode mode) {
+ return VpTexts.tr(
+ "label.videoplayer.audio_channel_mode." + mode.configValue(),
+ mode == AudioChannelMode.AUTO ? "Auto" : "Stereo"
+ );
+ }
+
+ private ClientVideoArea getArea(CommandContext s) {
+ if (checkInvalid(s, false)) return null;
+ String name = s.getArgument("area", String.class);
+ ClientVideoArea area = areas.get(name);
+ if (area == null) {
+ s.getSource().sendFeedback(VpTexts.tr("error.videoplayer.area_named_not_found", "No video area named %s", name).withStyle(ChatFormatting.RED));
+ return null;
+ }
+ return area;
+ }
+
+ private ClientVideoScreen getScreen(CommandContext s) {
+ if (checkInvalid(s, false)) return null;
+ ClientVideoArea area = getArea(s);
+ if (area == null) return null;
+ String name = s.getArgument("screen", String.class);
+ ClientVideoScreen screen = area.getScreen(name);
+ if (screen == null) {
+ s.getSource().sendFeedback(VpTexts.tr("error.videoplayer.screen_not_found", "Screen not found").withStyle(ChatFormatting.RED));
+ return null;
+ }
+ return screen;
+ }
+
+ private boolean checkInvalid(CommandContext s, boolean checkScreen) {
+ if (!connected && !config.alwaysConnected) {
+ s.getSource().sendFeedback(VpTexts.tr("error.videoplayer.not_connected", "Not connected to server").withStyle(ChatFormatting.RED));
+ return true;
+ }
+ if (checkScreen && currentScreen == null) {
+ if (isInArea) {
+ s.getSource().sendFeedback(VpTexts.tr("error.videoplayer.current_area_no_main_screen", "Current video area has no main screen").withStyle(ChatFormatting.RED));
+ } else {
+ s.getSource().sendFeedback(VpTexts.tr("error.videoplayer.not_inside_area", "You are not inside a video area").withStyle(ChatFormatting.RED));
+ }
+ return true;
+ }
+ return false;
+ }
+
+ private boolean checkInvalidLooking(CommandContext s) {
+ if (!connected && !config.alwaysConnected) {
+ s.getSource().sendFeedback(VpTexts.tr("error.videoplayer.not_connected", "Not connected to server").withStyle(ChatFormatting.RED));
+ return true;
+ }
+ if (currentLooking == null) {
+ s.getSource().sendFeedback(VpTexts.tr("error.videoplayer.not_looking_at_screen", "You are not looking at a screen").withStyle(ChatFormatting.RED));
+ return true;
+ }
+ return false;
+ }
+
+ private static void updateBossBar() {
+ ClientPacketListener handler = client.getConnection();
+ if (handler == null) {
+ bossBarAdded = false;
+ return;
+ }
+ if (currentLooking != null) {
+ if (!bossBarAdded) {
+ handler.handleBossUpdate(ClientboundBossEventPacket.createAddPacket(bossBar));
+ bossBarAdded = true;
+ }
+ ClientVideoScreen screen = currentLooking.getScreen();
+ VideoInfo info = screen.currentDisplayInfo();
+ if (info != null && screen.player != null) {
+ String name = info.name();
+ long progress = System.currentTimeMillis() - screen.getStartTime();
+ long totalProgress = screen.player.getTotalProgress();
+ String time;
+ if (totalProgress > 0) {
+ boolean showHour = progress >= 3600000 || totalProgress >= 3600000;
+ time = formatDuration(progress, showHour) + "/" + formatDuration(totalProgress, showHour);
+ bossBar.setProgress((float) progress / totalProgress);
+ } else {
+ time = formatDuration(progress, progress >= 3600000) + "/LIVE";
+ bossBar.setProgress(0);
+ }
+ bossBar.setName(Component.nullToEmpty(name + " " + time));
+ } else {
+ bossBar.setName(VpTexts.tr("label.videoplayer.none", "None"));
+ bossBar.setProgress(1);
+ }
+ handler.handleBossUpdate(ClientboundBossEventPacket.createUpdateNamePacket(bossBar));
+ handler.handleBossUpdate(ClientboundBossEventPacket.createUpdateProgressPacket(bossBar));
+ } else if (bossBarAdded) {
+ handler.handleBossUpdate(ClientboundBossEventPacket.createRemovePacket(bossBar.getId()));
+ bossBarAdded = false;
+ }
+ }
+
+ private static void checkInteract() {
+ Minecraft client = VideoPlayerClient.client;
+ if (client == null) return;
+
+ isInArea = false;
+ currentLooking = null;
+ currentScreen = null;
+ if (screens.isEmpty()) {
+ touchHandler.handle(null);
+ return;
+ }
+
+ float delta = VideoPlayerClient.client.getDeltaTracker().getGameTimeDeltaPartialTick(true);
+ Vec3 eyePos = client.player.getEyePosition(delta);
+ Vec3 lookVec = client.player.getViewVector(delta);
+
+ Vector3d lineStart = new Vector3d(eyePos.x, eyePos.y, eyePos.z);
+
+ remoteControl = false;
+ for (ItemStack item : List.of(client.player.getMainHandItem(), client.player.getOffhandItem())) {
+ if (!BuiltInRegistries.ITEM.getKey(item.getItem()).toString().equals(remoteControlName)) continue;
+ CustomModelData data = item.getComponents().get(DataComponents.CUSTOM_MODEL_DATA);
+ if (data == null) continue;
+ List id = data.floats();
+ if (id.isEmpty() || !id.contains(remoteControlId)) continue;
+ remoteControl = true;
+ }
+ Vec3 end = eyePos.add(lookVec.scale(remoteControl ? remoteControlRange : noControlRange));
+ Vector3d lineEnd = new Vector3d(end.x, end.y, end.z);
+
+ ArrayList list = new ArrayList<>();
+ for (ClientVideoScreen s : screens) {
+ if (!s.interactable) continue;
+ ClientVideoScreen screen = s.getTrackingScreen();
+ if (screen == null) continue;
+ Intersection.Result result = Intersection.intersect(lineStart, lineEnd, screen);
+ if (result.intersects) list.add(result);
+ }
+ Intersection.Result target = list.isEmpty() ? null : Collections.min(list, Comparator.comparingDouble(s -> s.preciseDistance));
+ currentLooking = target == null || target.screen == null ? null : target.screen;
+ touchHandler.handle(target);
+
+ if (currentLooking != null) {
+ currentScreen = currentLooking;
+ return;
+ }
+
+ currentScreen = null;
+ for (ClientVideoArea area : areas.values()) {
+ if (!area.loaded) continue;
+ isInArea = true;
+ for (VideoScreen screen : area.screens) {
+ ClientVideoScreen s = (ClientVideoScreen) screen;
+ if (s.interactable) {
+ currentScreen = s;
+ break;
+ }
+ }
+ }
+ }
+
+ public static boolean checkVersion(String v) {
+ return VideoProtocol.compatible(VideoPlayerMain.version, v);
+ }
+
+ public static void update() {
+ ClientPacketHandler.tickPendingRequests();
+ if (updated) return;
+ ProfilerFiller profiler = Profiler.get();
+ profiler.push("video");
+ profiler.push("updateFrame");
+ for (ClientVideoScreen screen : screens) {
+ if (screen.isPostUpdate()) continue;
+ screen.swapTexture();
+ screen.update();
+ }
+ profiler.popPush("checkInteract");
+ checkInteract();
+ profiler.popPush("updateBossBar");
+ updateBossBar();
+ profiler.pop();
+ profiler.pop();
+ }
+
+ private static void cleanupClientState() {
+ if (client.gui.screen() instanceof ServerStateScreen) {
+ client.gui.setScreen(null);
+ if (client.player != null) {
+ client.player.sendSystemMessage(VpTexts.tr("error.videoplayer.server_state_reset", "VideoPlayer server state was reset").withStyle(ChatFormatting.RED));
+ }
+ }
+ connected = false;
+ handshakeNonce = 0L;
+ joinHandshakePending = false;
+ for (ClientVideoArea area : new ArrayList<>(areas.values())) {
+ area.remove();
+ }
+ areas.clear();
+ for (ClientVideoScreen screen : new ArrayList<>(screens)) {
+ screen.cleanup();
+ }
+ screens.clear();
+ ScreenRenderer.clearExternalTextures();
+ ClientPacketHandler.resetPendingRequests();
+ ScreenVolumeCache.clear();
+ ClientDanmakuRenderer.clearCache();
+ Degree360Player.clearMeshCache();
+ ClientPermissionCache.clear();
+ isInArea = false;
+ currentLooking = null;
+ currentScreen = null;
+ remoteControl = false;
+ touchHandler.handle(null);
+ if (client.getConnection() != null) {
+ updateBossBar();
+ } else {
+ bossBarAdded = false;
+ }
+ VideoCreationEditor.instance().clear();
+ }
+
+ private static void registerExternalTextureReload() {
+ Identifier reloaderId = Identifier.fromNamespaceAndPath("videoplayer", "external_textures");
+ ResourceLoader loader = ResourceLoader.get(PackType.CLIENT_RESOURCES);
+ loader.registerReloadListener(reloaderId, new SimpleReloadListener() {
+ @Override
+ protected Void prepare(PreparableReloadListener.SharedState state) {
+ return null;
+ }
+
+ @Override
+ protected void apply(Void prepared, PreparableReloadListener.SharedState state) {
+ ScreenRenderer.clearExternalTextures();
+ }
+ });
+ loader.addListenerOrdering(ResourceReloaderKeys.Client.TEXTURES, reloaderId);
+ }
+
+ private static int openDiagnostics(CommandContext context) {
+ if (!connected && !config.alwaysConnected) {
+ context.getSource().sendFeedback(VpTexts.tr("error.videoplayer.not_connected", "Not connected to server").withStyle(ChatFormatting.RED));
+ return 0;
+ }
+ ClientVideoScreen selected = currentLooking != null ? currentLooking : currentScreen;
+ if (selected == null && !screens.isEmpty()) selected = screens.getFirst();
+ ClientVideoScreen target = selected;
+ if (target == null) {
+ client.gui.setScreen(VideoManagementScreen.diagnostics(VideoCreationEditor.instance(), null));
+ return 1;
+ }
+ ClientPacketHandler.openMenu(target, result -> {
+ if (!ClientPacketHandler.failed(result) && client.gui.screen() == null) {
+ client.gui.setScreen(VideoManagementScreen.diagnostics(VideoCreationEditor.instance(), target));
+ }
+ });
+ return 1;
+ }
+
+ public static void resetServerState() {
+ cleanupClientState();
+ }
+
+ public static boolean protocolRejected() {
+ return protocolRejected;
+ }
+
+ public static void acceptProtocol() {
+ protocolRejected = false;
+ }
+
+ static void handshakeResponse(String remoteVersion) {
+ connectionDiagnostics.handshakeResponse(remoteVersion);
+ }
+
+ static void connectionEstablished(String remoteVersion) {
+ connectionDiagnostics.connected(remoteVersion);
+ }
+
+ static void setHandshakeNonce(long nonce) {
+ handshakeNonce = nonce;
+ }
+
+ static void serverHandshakeReset() {
+ joinHandshakePending = false;
+ }
+
+ public static void rejectProtocol(String remoteVersion) {
+ if (!protocolRejected) cleanupClientState();
+ protocolRejected = true;
+ connected = false;
+ joinHandshakePending = false;
+ connectionDiagnostics.versionMismatch(remoteVersion);
+ if (connectionDiagnostics.snapshot().trigger() == VideoConnectionDiagnostics.Trigger.MANUAL_RETRY
+ || protocolMismatchShown || client.player == null) return;
+ protocolMismatchShown = true;
+ client.player.sendSystemMessage(VpTexts.tr(
+ "message.videoplayer.version_mismatch",
+ "VideoPlayer client version %s is not compatible with server %s",
+ VideoPlayerMain.version, remoteVersion == null || remoteVersion.isBlank() ? "unknown" : remoteVersion
+ ).withStyle(ChatFormatting.RED));
+ }
+
+ private static void tickHandshake(Minecraft client) {
+ if (client.getConnection() == null || client.player == null) {
+ joinHandshakePending = false;
+ connectionDiagnostics.disconnected();
+ return;
+ }
+ if (protocolRejected) return;
+ if (!ClientPlayNetworking.canSend(VideoPayload.ID)) {
+ connected = false;
+ connectionDiagnostics.channelUnavailable();
+ return;
+ }
+ connectionDiagnostics.channelAvailable();
+ connectionDiagnostics.tick();
+ if (!joinHandshakePending) return;
+ joinHandshakePending = false;
+ connectionDiagnostics.handshakeSent();
+ ClientPacketHandler.config(VideoPlayerMain.version);
+ }
+
+ public static VideoConnectionDiagnostics.Snapshot connectionSnapshot() {
+ return connectionDiagnostics.snapshot();
+ }
+
+ public static void reconnectServer() {
+ if (client.getConnection() == null || client.player == null) {
+ LOGGER.warn("VideoPlayer connection: trigger=manual_retry address={} state=failed reason=no_active_minecraft_connection",
+ logField(currentServerAddress()));
+ return;
+ }
+ if (!connectionDiagnostics.beginManualRetry(currentServerAddress(), VideoPlayerMain.version)) return;
+ protocolRejected = false;
+ protocolMismatchShown = false;
+ connected = false;
+ handshakeNonce = 0L;
+ joinHandshakePending = false;
+ if (!ClientPlayNetworking.canSend(VideoPayload.ID)) {
+ connectionDiagnostics.channelUnavailable();
+ return;
+ }
+ connectionDiagnostics.handshakeSent();
+ ClientPacketHandler.config(VideoPlayerMain.version);
+ }
+
+ private static String currentServerAddress() {
+ var server = client.getCurrentServer();
+ if (server == null || server.ip == null || server.ip.isBlank()) return "local";
+ return server.ip;
+ }
+
+ private static void logConnectionEvent(VideoConnectionDiagnostics.Event event) {
+ VideoConnectionDiagnostics.Snapshot snapshot = event.snapshot();
+ String trigger = snapshot.trigger().name().toLowerCase(Locale.ROOT);
+ String address = logField(snapshot.address());
+ switch (event.type()) {
+ case ATTEMPT_STARTED -> LOGGER.info(
+ "VideoPlayer connection: trigger={} address={} state=connecting local_version={}",
+ trigger, address, logField(snapshot.localVersion()));
+ case CHANNEL_UNAVAILABLE -> LOGGER.warn(
+ "VideoPlayer connection: trigger={} address={} state=failed reason=payload_channel_unavailable payload={} attempts={} elapsed_ms={}",
+ trigger, address, VideoPayload.VIDEO_PAYLOAD_ID, snapshot.attempts(), snapshot.elapsedMillis());
+ case CHANNEL_AVAILABLE -> LOGGER.info(
+ "VideoPlayer connection: trigger={} address={} state=connecting reason=payload_channel_available attempts={}",
+ trigger, address, snapshot.attempts());
+ case TIMED_OUT -> LOGGER.warn(
+ "VideoPlayer connection: trigger={} address={} state=timed_out reason=no_handshake_response attempts={} elapsed_ms={}",
+ trigger, address, snapshot.attempts(), snapshot.elapsedMillis());
+ case CONNECTED -> LOGGER.info(
+ "VideoPlayer connection: trigger={} address={} state=connected remote_version={} attempts={} elapsed_ms={}",
+ trigger, address, logField(snapshot.remoteVersion()), snapshot.attempts(), snapshot.elapsedMillis());
+ case VERSION_MISMATCH -> LOGGER.warn(
+ "VideoPlayer connection: trigger={} address={} state=failed reason=version_mismatch local_version={} remote_version={} attempts={} elapsed_ms={}",
+ trigger, address, logField(snapshot.localVersion()), logField(snapshot.remoteVersion()), snapshot.attempts(), snapshot.elapsedMillis());
+ case RETRY_BLOCKED -> LOGGER.warn(
+ "VideoPlayer connection: trigger=manual_retry address={} state=failed reason=version_mismatch_retry_blocked local_version={} remote_version={}",
+ address, logField(snapshot.localVersion()), logField(snapshot.remoteVersion()));
+ case DISCONNECTED -> LOGGER.info(
+ "VideoPlayer connection: trigger={} address={} state=disconnected attempts={} elapsed_ms={}",
+ trigger, address, snapshot.attempts(), snapshot.elapsedMillis());
+ }
+ notifyManualReconnect(event);
+ }
+
+ private static void notifyManualReconnect(VideoConnectionDiagnostics.Event event) {
+ VideoConnectionDiagnostics.Snapshot snapshot = event.snapshot();
+ if (snapshot.trigger() != VideoConnectionDiagnostics.Trigger.MANUAL_RETRY || client.player == null) return;
+ Component message;
+ ChatFormatting formatting;
+ switch (event.type()) {
+ case ATTEMPT_STARTED -> {
+ message = VpTexts.tr("message.videoplayer.reconnect_started", "Reconnecting to the VideoPlayer server...");
+ formatting = ChatFormatting.YELLOW;
+ }
+ case CHANNEL_UNAVAILABLE -> {
+ message = VpTexts.tr("message.videoplayer.reconnect_channel_unavailable",
+ "VideoPlayer server reconnect failed: the server did not register the communication channel");
+ formatting = ChatFormatting.RED;
+ }
+ case TIMED_OUT -> {
+ message = VpTexts.tr("message.videoplayer.reconnect_timed_out",
+ "VideoPlayer server reconnect failed: no handshake response within 10 seconds");
+ formatting = ChatFormatting.RED;
+ }
+ case CONNECTED -> {
+ message = VpTexts.tr("message.videoplayer.reconnect_success",
+ "VideoPlayer server reconnected. Server version: %s", reconnectVersion(snapshot.remoteVersion()));
+ formatting = ChatFormatting.GREEN;
+ }
+ case VERSION_MISMATCH, RETRY_BLOCKED -> {
+ message = VpTexts.tr("message.videoplayer.reconnect_version_mismatch",
+ "VideoPlayer server reconnect failed: local version %s is incompatible with server version %s",
+ reconnectVersion(snapshot.localVersion()), reconnectVersion(snapshot.remoteVersion()));
+ formatting = ChatFormatting.RED;
+ }
+ case CHANNEL_AVAILABLE, DISCONNECTED -> {
+ return;
+ }
+ default -> {
+ return;
+ }
+ }
+ client.player.sendSystemMessage(message.copy().withStyle(formatting));
+ }
+
+ private static String reconnectVersion(String version) {
+ if (version != null && !version.isBlank()) return version;
+ return VpTexts.tr("label.videoplayer.connection.unknown", "Unknown").getString();
+ }
+
+ private static String logField(String value) {
+ if (value == null || value.isBlank()) return "unknown";
+ StringBuilder sanitized = new StringBuilder(value.length());
+ for (int i = 0; i < value.length(); i++) {
+ char character = value.charAt(i);
+ sanitized.append(Character.isISOControl(character) || Character.isWhitespace(character) ? '_' : character);
+ }
+ return sanitized.toString();
+ }
+
+ public static void postUpdate() {
+ if (updated) return;
+ updated = true;
+ ProfilerFiller profiler = Profiler.get();
+ profiler.push("video");
+ profiler.push("updateFrame");
+ for (ClientVideoScreen screen : screens) {
+ if (!screen.isPostUpdate()) continue;
+ screen.swapTexture();
+ screen.update();
+ }
+ profiler.pop();
+ profiler.pop();
+ }
+
+ private static String formatDuration(long millis, boolean showHour) {
+ long all = millis / 1000;
+ long hours = all / 3600;
+ long minutes = (all % 3600) / 60;
+ long seconds = all % 60;
+
+ if (showHour) {
+ return String.format("%02d:%02d:%02d", hours, minutes, seconds);
+ } else {
+ return String.format("%02d:%02d", minutes, seconds);
+ }
+ }
+
+ public static void saveConfig() {
+ Path temporary = configPath.resolveSibling(configPath.getFileName() + ".tmp-" + UUID.randomUUID());
+ try {
+ applyNativePlatformConfig();
+ Files.createDirectories(configPath.getParent());
+ Files.writeString(temporary, gson.toJson(config), StandardCharsets.UTF_8, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
+ try {
+ Files.move(temporary, configPath, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
+ } catch (AtomicMoveNotSupportedException unsupported) {
+ Files.move(temporary, configPath, StandardCopyOption.REPLACE_EXISTING);
+ }
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ } finally {
+ try {
+ Files.deleteIfExists(temporary);
+ } catch (IOException ignored) {
+ }
+ }
+ }
+
+ public static void reloadConfig() {
+ loadConfig();
+ }
+
+ public static void markStartupGuideShown() {
+ if (config == null) return;
+ config.startupGuideShown = true;
+ saveConfig();
+ try {
+ Files.writeString(startupGuideVersionPath, VideoPlayerMain.version);
+ } catch (IOException e) {
+ LOGGER.warn("Failed to write startup guide version file {}", startupGuideVersionPath, e);
+ }
+ }
+
+ public static void applyNativePlatformConfig() {
+ if (config == null) return;
+ if (VideoPlayerMain.android) {
+ config.videoBackend = VideoBackends.VLC;
+ config.nativeVlcPlatform = NativeDownloadConfig.platformKey();
+ NativePackageManager.selectPlatform(NativePackageManager.BACKEND_VLC, config.nativeVlcPlatform);
+ StreamListener.configurePreferredBackend(VideoBackends.VLC);
+ } else {
+ config.nativeVlcPlatform = NativeDownloadConfig.normalizePlatformForCurrentOs(config.nativeVlcPlatform);
+ config.nativeMpvPlatform = NativeDownloadConfig.normalizePlatformForCurrentOs(config.nativeMpvPlatform);
+ NativePackageManager.selectPlatform(NativePackageManager.BACKEND_VLC, config.nativeVlcPlatform);
+ NativePackageManager.selectPlatform(NativePackageManager.BACKEND_MPV, config.nativeMpvPlatform);
+ }
+ StreamListener.configureProxy(config.nativeDownloadProxy);
+ YouTubeProvider.configureProxy(config.nativeDownloadProxy);
+ YouTubeProvider.configureCookies(config.youtubeCookiesFile, config.youtubeCookiesFromBrowser);
+ String effectiveYtdlPath = YtDlpManager.effectiveExecutable(config.mpvYtdlPath);
+ StreamListener.configureYtdlPath(effectiveYtdlPath);
+ YouTubeProvider.configureYtdlPath(effectiveYtdlPath);
+ }
+
+ public static NativeDownloadConfig nativeDownloadConfig() {
+ if (config == null) {
+ return NativeDownloadConfig.load();
+ }
+ if (config.nativeDownloadUrls == null) {
+ config.nativeDownloadUrls = NativeDownloadConfig.load();
+ }
+ return config.nativeDownloadUrls;
+ }
+
+ public static void applyConfiguredVolume() {
+ config.volume = Math.clamp(config.volume, 0, 100);
+ for (ClientVideoScreen screen : screens) {
+ if (screen.player instanceof VideoPlayer player && VideoBackends.VLC.equals(player.backendName())) {
+ player.setVolume(config.volume);
+ }
+ }
+ }
+
+ public static AudioChannelMode activeAudioChannelMode() {
+ return activeAudioChannelMode;
+ }
+
+ private static void loadConfig() {
+ boolean existed = Files.exists(configPath);
+ boolean changed = false;
+ boolean preserveInvalidFile = false;
+ try {
+ String serializedConfig = Files.readString(configPath);
+ JsonElement configJson = JsonParser.parseString(serializedConfig);
+ if (!configJson.isJsonObject()) throw new IllegalArgumentException("Client configuration root must be an object");
+ JsonObject configObject = configJson.getAsJsonObject();
+ JsonElement configuredAudioChannelMode = configObject.get("audioChannelMode");
+ if (!AudioChannelMode.isCanonicalJsonValue(configuredAudioChannelMode)) {
+ configObject.addProperty("audioChannelMode", AudioChannelMode.normalizeJson(configuredAudioChannelMode).configValue());
+ changed = true;
+ }
+ config = gson.fromJson(configObject, Config.class);
+ if (config == null) config = new Config();
+ } catch (Exception e) {
+ config = new Config();
+ changed = true;
+ preserveInvalidFile = existed;
+ LOGGER.warn("Failed to read client configuration {}; keeping the original file", configPath, e);
+ }
+ config.nativeDownloadUrls = NativeDownloadConfig.load();
+ if (config.startupGuideShown == null) {
+ config.startupGuideShown = existed;
+ changed = true;
+ }
+ boolean currentGuideVersionShown = false;
+ try {
+ currentGuideVersionShown = Files.isRegularFile(startupGuideVersionPath)
+ && VideoPlayerMain.version.equals(Files.readString(startupGuideVersionPath).trim());
+ } catch (IOException e) {
+ LOGGER.warn("Failed to read startup guide version file {}", startupGuideVersionPath, e);
+ }
+ if (!currentGuideVersionShown && !Boolean.FALSE.equals(config.startupGuideShown)) {
+ config.startupGuideShown = false;
+ changed = true;
+ }
+ config.videoBackend = VideoBackends.normalize(config.videoBackend);
+ String audioChannelMode = AudioChannelMode.normalize(config.audioChannelMode).configValue();
+ if (!Objects.equals(config.audioChannelMode, audioChannelMode)) {
+ config.audioChannelMode = audioChannelMode;
+ changed = true;
+ }
+ String nativeVlcPlatform = NativeDownloadConfig.normalizePlatformForCurrentOs(config.nativeVlcPlatform);
+ if (!Objects.equals(config.nativeVlcPlatform, nativeVlcPlatform)) {
+ config.nativeVlcPlatform = nativeVlcPlatform;
+ changed = true;
+ }
+ String nativeMpvPlatform = NativeDownloadConfig.normalizePlatformForCurrentOs(config.nativeMpvPlatform);
+ if (!Objects.equals(config.nativeMpvPlatform, nativeMpvPlatform)) {
+ config.nativeMpvPlatform = nativeMpvPlatform;
+ changed = true;
+ }
+ if (config.nativeDownloadProxy == null) {
+ config.nativeDownloadProxy = "";
+ changed = true;
+ }
+ if (config.mpvYtdlPath == null) {
+ config.mpvYtdlPath = "";
+ changed = true;
+ } else if (YtDlpManager.isCurrentManagedExecutable(config.mpvYtdlPath)) {
+ config.mpvYtdlPath = "";
+ changed = true;
+ }
+ if (config.youtubeCookiesFile == null) {
+ config.youtubeCookiesFile = "";
+ changed = true;
+ }
+ if (config.youtubeCookiesFromBrowser == null) {
+ config.youtubeCookiesFromBrowser = "";
+ changed = true;
+ }
+ applyNativePlatformConfig();
+ config.volume = Math.clamp(config.volume, 0, 100);
+ config.brightness = Math.clamp(config.brightness, 0, 100);
+ config.danmakuRollingRangePercent = switch (config.danmakuRollingRangePercent) {
+ case 25, 50, 75, 100 -> config.danmakuRollingRangePercent;
+ default -> 50;
+ };
+ config.danmakuSpeedPreset = Math.clamp(config.danmakuSpeedPreset, 0, 4);
+ config.danmakuDensityPreset = Math.clamp(config.danmakuDensityPreset, 0, 2);
+ config.danmakuOpacity = Math.clamp(config.danmakuOpacity, 20, 100);
+ config.danmakuScalePercent = Math.clamp(config.danmakuScalePercent, 50, 170);
+ int bilibiliQuality = BiliQuality.normalizeClient(config.bilibiliQuality);
+ if (config.bilibiliQuality != bilibiliQuality) {
+ config.bilibiliQuality = bilibiliQuality;
+ changed = true;
+ }
+ int youtubeQuality = YouTubeQuality.normalizeClient(config.youtubeQuality);
+ if (config.youtubeQuality != youtubeQuality) {
+ config.youtubeQuality = youtubeQuality;
+ changed = true;
+ }
+ if (changed && !preserveInvalidFile) saveConfig();
+ }
+
+ private static void registerStartupGuide() {
+ ClientTickEvents.END_CLIENT_TICK.register(client -> {
+ if (startupGuideOpened || config == null || Boolean.TRUE.equals(config.startupGuideShown)) return;
+ if (client.level != null || client.gui.screen() == null || client.gui.screen() instanceof StartupGuideScreen) return;
+ startupGuideOpened = true;
+ client.gui.setScreen(new StartupGuideScreen(client.gui.screen()));
+ });
+ }
+
+ private static void registerStartupGuideScreenOpener() {
+ ClientTickEvents.END_CLIENT_TICK.register(client -> {
+ if (!pendingStartupGuideScreen) return;
+ pendingStartupGuideScreen = false;
+ if (client.gui.screen() instanceof StartupGuideScreen) return;
+ client.gui.setScreen(new StartupGuideScreen(client.gui.screen()));
+ });
+ }
+
+ private static void registerBiliLoginScreenOpener() {
+ ClientTickEvents.END_CLIENT_TICK.register(client -> {
+ if (!pendingBiliLoginScreen) return;
+ pendingBiliLoginScreen = false;
+ if (client.gui.screen() instanceof BiliLoginScreen) return;
+ client.gui.setScreen(new BiliLoginScreen(client.gui.screen()));
+ });
+ }
+
+ public static int openYouTubeAuth(CommandContext ignored) {
+ pendingYouTubeAuthScreen = true;
+ return 1;
+ }
+
+ private static void registerYouTubeAuthScreenOpener() {
+ ClientTickEvents.END_CLIENT_TICK.register(client -> {
+ if (!pendingYouTubeAuthScreen) return;
+ pendingYouTubeAuthScreen = false;
+ if (client.gui.screen() instanceof YouTubeAuthScreen) return;
+ client.gui.setScreen(new YouTubeAuthScreen(client.gui.screen()));
+ });
+ }
+
+}
diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/BiliLoginScreen.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/BiliLoginScreen.java
new file mode 100644
index 0000000..4609b2f
--- /dev/null
+++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/BiliLoginScreen.java
@@ -0,0 +1,317 @@
+package com.github.squi2rel.vp.creation;
+
+import com.github.squi2rel.vp.danmaku.BiliQrLoginClient;
+import com.github.squi2rel.vp.i18n.VpTexts;
+import com.github.squi2rel.vp.i18n.VpTranslation;
+import com.google.zxing.BarcodeFormat;
+import com.google.zxing.EncodeHintType;
+import com.google.zxing.WriterException;
+import com.google.zxing.common.BitMatrix;
+import com.google.zxing.qrcode.QRCodeWriter;
+import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
+import com.mojang.blaze3d.platform.NativeImage;
+import java.nio.charset.StandardCharsets;
+import java.util.EnumMap;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.atomic.AtomicInteger;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.GuiGraphicsExtractor;
+import net.minecraft.client.gui.screens.Screen;
+import net.minecraft.client.renderer.texture.DynamicTexture;
+import net.minecraft.client.renderer.texture.TextureManager;
+import net.minecraft.network.chat.Component;
+import net.minecraft.resources.Identifier;
+
+public class BiliLoginScreen extends Screen {
+ private static final VpUiTheme THEME = VpUiTheme.classic();
+ private static final AtomicInteger TEXTURE_COUNTER = new AtomicInteger();
+ private static final int PANEL_WIDTH = 420;
+ private static final int PANEL_HEIGHT = 328;
+ private static final int QR_PIXELS = 256;
+ private static final long POLL_INTERVAL_MS = 1000L;
+
+ private final Screen parent;
+
+ private VpButtonWidget refreshButton;
+ private VpButtonWidget closeButton;
+ private BiliQrLoginClient.QrCode qrCode;
+ private BiliQrLoginClient.State state = BiliQrLoginClient.State.WAITING;
+ private VpTranslation status = VpTranslation.of("message.videoplayer.bili_login_loading", "Loading QR code");
+ private CompletableFuture> activeRequest;
+ private long nextPollAt;
+ private int requestToken;
+ private boolean closing;
+
+ private Identifier qrIdentifier;
+ private DynamicTexture qrTexture;
+
+ public BiliLoginScreen(Screen parent) {
+ super(VpTexts.tr("screen.videoplayer.bili_login", "Bilibili Login"));
+ this.parent = parent;
+ }
+
+ @Override
+ protected void init() {
+ int panelW = panelWidth();
+ int left = (width - panelW) / 2;
+ int top = panelTop();
+ int buttonY = top + panelHeight() - 34;
+ int buttonW = Math.max(88, Math.min(120, (panelW - 56) / 2));
+ int gap = 8;
+ int rightButtonX = left + panelW - 24 - buttonW;
+
+ refreshButton = new VpButtonWidget(rightButtonX - gap - buttonW, buttonY, buttonW, 22,
+ VpTexts.tr("button.videoplayer.refresh", "Refresh"), ignored -> startGenerate(), THEME);
+ closeButton = new VpButtonWidget(rightButtonX, buttonY, buttonW, 22,
+ VpTexts.tr("button.videoplayer.close", "Close"), ignored -> onClose(), THEME);
+ addRenderableWidget(refreshButton);
+ addRenderableWidget(closeButton);
+ syncButtons();
+
+ if (qrCode == null && activeRequest == null) {
+ startGenerate();
+ }
+ }
+
+ @Override
+ public void tick() {
+ if (closing || activeRequest != null || qrCode == null) {
+ syncButtons();
+ return;
+ }
+ if ((state == BiliQrLoginClient.State.WAITING || state == BiliQrLoginClient.State.SCANNED)
+ && System.currentTimeMillis() >= nextPollAt) {
+ startPoll();
+ }
+ syncButtons();
+ }
+
+ @Override
+ public void onClose() {
+ if (minecraft != null) {
+ minecraft.gui.setScreen(parent);
+ }
+ }
+
+ @Override
+ public void removed() {
+ closing = true;
+ requestToken++;
+ CompletableFuture> task = activeRequest;
+ activeRequest = null;
+ if (task != null) task.cancel(true);
+ destroyQrTexture();
+ }
+
+ @Override
+ public boolean isPauseScreen() {
+ return false;
+ }
+
+ @Override
+ public void extractRenderState(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
+ context.fill(0, 0, width, height, 0xB0000000);
+
+ int panelW = panelWidth();
+ int panelH = panelHeight();
+ int left = (width - panelW) / 2;
+ int top = panelTop();
+ int right = left + panelW;
+ int bottom = top + panelH;
+
+ context.fill(left, top, right, bottom, THEME.panelBackgroundColor());
+ context.outline(left, top, panelW, panelH, THEME.panelBorderColor());
+ context.centeredText(font, title, width / 2, top + 14, THEME.primaryTextColor());
+
+ int qrSize = qrDisplaySize(panelW, panelH);
+ int qrX = left + (panelW - qrSize) / 2;
+ int qrY = top + 42;
+ VpUiRenderer.drawBox(context, qrX - 4, qrY - 4, qrSize + 8, qrSize + 8, 0xFFFFFFFF, THEME.panelBorderColor());
+ if (qrIdentifier != null) {
+ context.blit(qrIdentifier, qrX, qrY, qrX + qrSize, qrY + qrSize, 0, 1, 0, 1);
+ }
+
+ Component statusText = VpTexts.text(status);
+ int statusY = Math.min(bottom - 62, qrY + qrSize + 16);
+ context.centeredText(font, statusText, width / 2, statusY, statusColor());
+
+ super.extractRenderState(context, mouseX, mouseY, delta);
+ }
+
+ private void startGenerate() {
+ if (closing) return;
+ int token = ++requestToken;
+ CompletableFuture> old = activeRequest;
+ if (old != null) old.cancel(true);
+ qrCode = null;
+ state = BiliQrLoginClient.State.WAITING;
+ status = VpTranslation.of("message.videoplayer.bili_login_loading", "Loading QR code");
+ destroyQrTexture();
+ syncButtons();
+
+ CompletableFuture task = BiliQrLoginClient.generateAsync();
+ activeRequest = task;
+ task.whenComplete((qr, error) -> runOnClient(() -> {
+ if (!isCurrent(token)) return;
+ activeRequest = null;
+ if (error != null) {
+ if (!isCancellation(error)) setError(error);
+ syncButtons();
+ return;
+ }
+ try {
+ qrCode = qr;
+ createQrTexture(qr.url());
+ state = BiliQrLoginClient.State.WAITING;
+ status = VpTranslation.of("message.videoplayer.bili_login_waiting", "Waiting for scan");
+ nextPollAt = 0L;
+ } catch (Exception e) {
+ setError(e);
+ }
+ syncButtons();
+ }));
+ }
+
+ private void startPoll() {
+ if (closing || qrCode == null) return;
+ int token = ++requestToken;
+ nextPollAt = System.currentTimeMillis() + POLL_INTERVAL_MS;
+ CompletableFuture task = BiliQrLoginClient.pollAsync(qrCode.qrcodeKey());
+ activeRequest = task;
+ syncButtons();
+ task.whenComplete((result, error) -> runOnClient(() -> {
+ if (!isCurrent(token)) return;
+ activeRequest = null;
+ if (error != null) {
+ if (!isCancellation(error)) setError(error);
+ syncButtons();
+ return;
+ }
+ state = result.state();
+ status = result.message();
+ if (state == BiliQrLoginClient.State.WAITING || state == BiliQrLoginClient.State.SCANNED) {
+ nextPollAt = System.currentTimeMillis() + POLL_INTERVAL_MS;
+ }
+ syncButtons();
+ }));
+ }
+
+ private void createQrTexture(String url) throws WriterException {
+ NativeImage image = createQrImage(url);
+ Identifier identifier = Identifier.fromNamespaceAndPath("videoplayer", "bili_login/qr/" + TEXTURE_COUNTER.incrementAndGet());
+ DynamicTexture texture = null;
+ boolean registered = false;
+ try {
+ texture = new DynamicTexture(() -> "VideoPlayer Bilibili QR", image);
+ Minecraft.getInstance().getTextureManager().register(identifier, texture);
+ registered = true;
+ qrIdentifier = identifier;
+ qrTexture = texture;
+ } finally {
+ if (!registered) {
+ if (texture != null) texture.close();
+ else image.close();
+ }
+ }
+ }
+
+ private NativeImage createQrImage(String url) throws WriterException {
+ Map hints = new EnumMap<>(EncodeHintType.class);
+ hints.put(EncodeHintType.CHARACTER_SET, StandardCharsets.UTF_8.name());
+ hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M);
+ hints.put(EncodeHintType.MARGIN, 2);
+ BitMatrix matrix = new QRCodeWriter().encode(url, BarcodeFormat.QR_CODE, QR_PIXELS, QR_PIXELS, hints);
+ NativeImage image = new NativeImage(QR_PIXELS, QR_PIXELS, false);
+ for (int y = 0; y < QR_PIXELS; y++) {
+ for (int x = 0; x < QR_PIXELS; x++) {
+ image.setPixel(x, y, matrix.get(x, y) ? 0xFF000000 : 0xFFFFFFFF);
+ }
+ }
+ return image;
+ }
+
+ private void destroyQrTexture() {
+ Identifier identifier = qrIdentifier;
+ DynamicTexture texture = qrTexture;
+ qrIdentifier = null;
+ qrTexture = null;
+ if (identifier != null) {
+ TextureManager textureManager = Minecraft.getInstance().getTextureManager();
+ try {
+ textureManager.release(identifier);
+ return;
+ } catch (RuntimeException ignored) {
+ // Fall through to close the texture directly.
+ }
+ }
+ if (texture != null) texture.close();
+ }
+
+ private void setError(Throwable error) {
+ state = BiliQrLoginClient.State.ERROR;
+ status = VpTranslation.of("message.videoplayer.bili_login_failed", "Bilibili login failed: %s", publicMessage(error));
+ }
+
+ private String publicMessage(Throwable error) {
+ Throwable root = unwrap(error);
+ String message = root.getMessage();
+ if (message == null || message.isBlank()) return root.getClass().getSimpleName();
+ return message;
+ }
+
+ private Throwable unwrap(Throwable error) {
+ Throwable result = error == null ? new RuntimeException("unknown") : error;
+ while (result instanceof CompletionException && result.getCause() != null) {
+ result = result.getCause();
+ }
+ return result;
+ }
+
+ private boolean isCancellation(Throwable error) {
+ Throwable root = unwrap(error);
+ return root instanceof CancellationException;
+ }
+
+ private boolean isCurrent(int token) {
+ return !closing && token == requestToken;
+ }
+
+ private void runOnClient(Runnable task) {
+ Minecraft client = Minecraft.getInstance();
+ if (client == null) return;
+ client.execute(task);
+ }
+
+ private void syncButtons() {
+ if (refreshButton != null) refreshButton.active = activeRequest == null && !closing;
+ if (closeButton != null) closeButton.active = true;
+ }
+
+ private int statusColor() {
+ return switch (state) {
+ case SCANNED -> THEME.accentColor();
+ case EXPIRED, ERROR -> THEME.errorColor();
+ case SUCCESS -> THEME.executionColor();
+ default -> THEME.secondaryTextColor();
+ };
+ }
+
+ private int panelWidth() {
+ return Math.max(160, Math.min(PANEL_WIDTH, Math.max(0, width - 32)));
+ }
+
+ private int panelHeight() {
+ return Math.max(220, Math.min(PANEL_HEIGHT, Math.max(0, height - 32)));
+ }
+
+ private int panelTop() {
+ return Math.max(16, (height - panelHeight()) / 2);
+ }
+
+ private int qrDisplaySize(int panelW, int panelH) {
+ return Math.max(96, Math.min(220, Math.max(0, Math.min(panelW - 76, panelH - 128))));
+ }
+}
diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/FilteredEditBox.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/FilteredEditBox.java
new file mode 100644
index 0000000..d65086d
--- /dev/null
+++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/FilteredEditBox.java
@@ -0,0 +1,33 @@
+package com.github.squi2rel.vp.creation;
+
+import java.util.Objects;
+import java.util.function.Predicate;
+import net.minecraft.client.gui.Font;
+import net.minecraft.client.gui.components.EditBox;
+import net.minecraft.network.chat.Component;
+
+class FilteredEditBox extends EditBox {
+ private Predicate filter = value -> true;
+
+ FilteredEditBox(Font font, int x, int y, int width, int height, Component message) {
+ super(font, x, y, width, height, message);
+ }
+
+ void setFilter(Predicate filter) {
+ this.filter = Objects.requireNonNull(filter, "filter");
+ }
+
+ @Override
+ public void setValue(String value) {
+ if (filter.test(value)) {
+ super.setValue(value);
+ }
+ }
+
+ @Override
+ public void insertText(String insertion) {
+ if (TextInputFilter.accepts(filter, getValue(), getCursorPosition(), getHighlighted(), insertion)) {
+ super.insertText(insertion);
+ }
+ }
+}
diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/IdlePlayListScreen.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/IdlePlayListScreen.java
new file mode 100644
index 0000000..9a285e4
--- /dev/null
+++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/IdlePlayListScreen.java
@@ -0,0 +1,427 @@
+package com.github.squi2rel.vp.creation;
+
+import com.github.squi2rel.vp.ClientPacketHandler;
+import com.github.squi2rel.vp.ClientPermissionCache;
+import com.github.squi2rel.vp.i18n.VpTexts;
+import com.github.squi2rel.vp.network.ByteBufUtils;
+import com.github.squi2rel.vp.permission.VideoPermissionAction;
+import com.github.squi2rel.vp.provider.VideoUrlNormalizer;
+import com.github.squi2rel.vp.video.ClientVideoScreen;
+import com.github.squi2rel.vp.video.IdlePlayEntry;
+import com.github.squi2rel.vp.video.VideoScreen;
+import java.util.function.Consumer;
+import net.minecraft.ChatFormatting;
+import net.minecraft.client.gui.GuiGraphicsExtractor;
+import net.minecraft.client.gui.screens.Screen;
+import net.minecraft.client.input.MouseButtonEvent;
+import net.minecraft.network.chat.Component;
+
+public class IdlePlayListScreen extends Screen implements ServerStateScreen {
+ private static final int GAP = 8;
+ private static final int CONTROL_HEIGHT = 18;
+ private static final int ROW_HEIGHT = 32;
+ private static final int LABEL_OFFSET = 11;
+ private static final VpUiTheme THEME = VpUiTheme.classic();
+
+ private final Screen parent;
+ private final ClientVideoScreen screen;
+ private VpTextFieldWidget urlField;
+ private VpTextFieldWidget priorityField;
+ private String urlDraft = "";
+ private String priorityDraft = "0";
+ private int listScroll;
+ private int listTop;
+ private int listBottom;
+ private int listX;
+ private int listW;
+ private boolean requestPending;
+ private VpButtonWidget addButton;
+ private VpButtonWidget modeButton;
+ private VpButtonWidget clearButton;
+
+ public IdlePlayListScreen(Screen parent, ClientVideoScreen screen) {
+ this(parent, screen, "", 0);
+ }
+
+ private IdlePlayListScreen(Screen parent, ClientVideoScreen screen, String urlDraft, int listScroll) {
+ super(VpTexts.tr("screen.videoplayer.idle_play_list", "Idle Play List"));
+ this.parent = parent;
+ this.screen = screen;
+ this.urlDraft = urlDraft == null ? "" : urlDraft;
+ this.listScroll = Math.max(0, listScroll);
+ }
+
+ @Override
+ protected void init() {
+ computeLayout();
+ int x = listX;
+ int contentW = listW;
+ int row = 54;
+
+ int addW = 56;
+ int priorityW = 42;
+ int urlW = Math.max(80, contentW - addW - priorityW - GAP * 2);
+ urlField = new VpTextFieldWidget(font, x, row, urlW, CONTROL_HEIGHT, Component.empty(), THEME);
+ urlField.setMaxLength(VideoScreen.MAX_IDLE_PLAY_URL_BYTES);
+ urlField.setFilter(VideoScreen::validIdlePlayUrlInput);
+ urlField.setValue(urlDraft);
+ addRenderableWidget(urlField);
+ priorityField = new VpTextFieldWidget(font, x + urlW + GAP, row, priorityW, CONTROL_HEIGHT, Component.empty(), THEME);
+ priorityField.setMaxLength(3);
+ priorityField.setFilter(value -> value.isEmpty() || value.chars().allMatch(Character::isDigit));
+ priorityField.setValue(priorityDraft);
+ priorityField.setResponder(value -> priorityDraft = value);
+ addRenderableWidget(priorityField);
+ addButton = button(VpTexts.tr("button.videoplayer.add", "Add"), x + urlW + priorityW + GAP * 2, row, addW, this::addIdlePlayUrl);
+
+ row += 28;
+ int modeW = Math.max(80, (contentW - GAP) / 2);
+ modeButton = button(idlePlayModeText(), x, row, modeW, this::toggleIdlePlayMode)
+ .selected(screen != null && screen.idlePlayRandom);
+ clearButton = button(VpTexts.tr("button.videoplayer.clear", "Clear"), x + modeW + GAP, row, modeW, this::clearIdlePlay).danger(true);
+ refreshControls();
+
+ int closeW = 72;
+ button(VpTexts.tr("button.videoplayer.close", "Close"), x + Math.max(0, contentW - closeW), Math.max(108, height - 40), closeW, this::onClose);
+ }
+
+ @Override
+ public boolean isPauseScreen() {
+ return false;
+ }
+
+ @Override
+ public void onClose() {
+ if (minecraft != null) {
+ minecraft.gui.setScreen(parent);
+ }
+ }
+
+ @Override
+ public void extractBackground(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
+ context.fill(0, 0, width, height, VpUiRenderer.withAlpha(THEME.canvasBackgroundColor(), 0xCC));
+ }
+
+ @Override
+ public void extractRenderState(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
+ computeLayout();
+ extractBackground(context, mouseX, mouseY, delta);
+ int panelX = 18;
+ int panelY = 18;
+ int panelW = Math.max(260, width - 36);
+ int panelH = Math.max(120, height - 36);
+ VpUiRenderer.drawBox(context, panelX, panelY, panelW, panelH, THEME.panelBackgroundColor(), THEME.panelBorderColor());
+
+ drawCenteredText(context, title, width / 2, 28, THEME.primaryTextColor());
+ drawLabel(context, "URL", listX, 54 - LABEL_OFFSET, THEME.secondaryTextColor());
+ drawLabel(context, VpTexts.tr("label.videoplayer.priority", "Priority"), priorityField == null ? listX : priorityField.getX(), 54 - LABEL_OFFSET, THEME.secondaryTextColor());
+ drawLabel(context, VpTexts.tr("label.videoplayer.play_mode", "Play Mode"), listX, 82 - LABEL_OFFSET, THEME.secondaryTextColor());
+ drawLabel(context, VpTexts.tr("label.videoplayer.idle_play_list", "Idle Play List"), listX, listTop - LABEL_OFFSET, THEME.secondaryTextColor());
+
+ refreshControls();
+ super.extractRenderState(context, mouseX, mouseY, delta);
+ drawIdleList(context, mouseX, mouseY);
+ drawScrollbar(context);
+ }
+
+ @Override
+ public boolean mouseClicked(MouseButtonEvent click, boolean doubleClick) {
+ if (click.button() == 0 && clickListControls(click.x(), click.y())) {
+ return true;
+ }
+ return super.mouseClicked(click, doubleClick);
+ }
+
+ @Override
+ public boolean mouseScrolled(double mouseX, double mouseY, double horizontalAmount, double verticalAmount) {
+ if (!inside(mouseX, mouseY, listX, listTop, listX + listW, listBottom)) {
+ return super.mouseScrolled(mouseX, mouseY, horizontalAmount, verticalAmount);
+ }
+ int delta = (int) Math.round(-verticalAmount * ROW_HEIGHT);
+ int next = Math.clamp(listScroll + delta, 0, maxListScroll());
+ if (next == listScroll) {
+ return false;
+ }
+ if (urlField != null) {
+ urlDraft = urlField.getValue();
+ }
+ listScroll = next;
+ return true;
+ }
+
+ private void computeLayout() {
+ int panelX = 18;
+ int panelW = Math.max(260, width - 36);
+ listX = panelX + 12;
+ listW = Math.max(120, panelW - 24);
+ listTop = 122;
+ listBottom = Math.max(listTop + ROW_HEIGHT, height - 58);
+ listScroll = Math.clamp(listScroll, 0, maxListScroll());
+ }
+
+ private void drawIdleList(GuiGraphicsExtractor context, int mouseX, int mouseY) {
+ VpUiRenderer.drawBox(context, listX, listTop, listW, listBottom - listTop, VpUiRenderer.darken(THEME.nodeBodyColor(), 0.06f), THEME.panelBorderColor());
+ context.enableScissor(listX + 1, listTop + 1, listX + listW - 1, listBottom - 1);
+ if (screen == null || screen.idlePlayEntries.isEmpty()) {
+ drawLabel(context, VpTexts.tr("message.videoplayer.idle_play_empty", "Idle list is empty"), listX + 8, listTop + 8, THEME.secondaryTextColor());
+ context.disableScissor();
+ return;
+ }
+
+ int controlsW = 76;
+ int textW = Math.max(40, listW - controlsW - 20);
+ for (int i = 0; i < screen.idlePlayEntries.size(); i++) {
+ IdlePlayEntry entry = screen.idlePlayEntries.get(i);
+ int rowY = listTop + 4 + i * ROW_HEIGHT - listScroll;
+ if (rowY + ROW_HEIGHT < listTop || rowY > listBottom) {
+ continue;
+ }
+ int fill = i % 2 == 0 ? VpUiRenderer.withAlpha(THEME.nodeBodyColor(), 0x80) : VpUiRenderer.withAlpha(THEME.nodeHeaderColor(), 0x66);
+ context.fill(listX + 4, rowY - 1, listX + listW - 4, rowY + ROW_HEIGHT - 2, fill);
+ drawLabel(context, (i + 1) + ". " + trimToWidth(entry.url(), textW), listX + 8, rowY + 3, THEME.secondaryTextColor());
+ String owner = entry.legacyOwner()
+ ? VpTexts.tr("label.videoplayer.idle_play_legacy_owner", "Unknown (legacy config)").getString()
+ : entry.addedByName();
+ drawLabel(context, trimToWidth(VpTexts.tr("label.videoplayer.idle_play_entry_meta", "Added by: %s | Priority: %s", owner, entry.priority()).getString(), textW),
+ listX + 8, rowY + 16, THEME.secondaryTextColor());
+ int controlX = listControlX();
+ drawListButton(context, "P-", controlX, rowY + 6, 24, canEditIdlePlay() && entry.priority() > IdlePlayEntry.MIN_PRIORITY,
+ inside(mouseX, mouseY, controlX, rowY + 6, controlX + 24, rowY + 24));
+ drawListButton(context, "P+", controlX + 26, rowY + 6, 24, canEditIdlePlay() && entry.priority() < IdlePlayEntry.MAX_PRIORITY,
+ inside(mouseX, mouseY, controlX + 26, rowY + 6, controlX + 50, rowY + 24));
+ drawListButton(context, "-", controlX + 52, rowY + 6, 24, canEditIdlePlay(),
+ inside(mouseX, mouseY, controlX + 52, rowY + 6, controlX + 76, rowY + 24));
+ }
+ context.disableScissor();
+ }
+
+ private void drawListButton(GuiGraphicsExtractor context, String label, int x, int y, int width, boolean active, boolean hovered) {
+ int fill = VpUiRenderer.darken(THEME.nodeBodyColor(), 0.04f);
+ if (hovered && active) {
+ fill = VpUiRenderer.blend(fill, THEME.errorColor(), 0.12f);
+ }
+ int border = active && hovered ? THEME.errorColor() : THEME.panelBorderColor();
+ int text = active ? (hovered ? THEME.primaryTextColor() : THEME.secondaryTextColor()) : VpUiRenderer.blend(THEME.secondaryTextColor(), THEME.canvasBackgroundColor(), 0.45f);
+ VpUiRenderer.drawBox(context, x, y, width, CONTROL_HEIGHT, fill, border);
+ drawCenteredText(context, Component.literal(label), x + width / 2, y + 5, text);
+ }
+
+ private boolean clickListControls(double mouseX, double mouseY) {
+ if (!canEditIdlePlay() || screen.idlePlayEntries.isEmpty()) {
+ return false;
+ }
+ if (!inside(mouseX, mouseY, listControlX(), listTop + 4, listControlX() + 76, listBottom)) {
+ return false;
+ }
+ double localY = mouseY - listTop - 4 + listScroll;
+ if (localY < 0) {
+ return false;
+ }
+ int index = (int) (localY / ROW_HEIGHT);
+ if (index < 0 || index >= screen.idlePlayEntries.size()) {
+ return false;
+ }
+ int rowY = listTop + 4 + index * ROW_HEIGHT - listScroll;
+ int controlX = listControlX();
+ if (!inside(mouseX, mouseY, controlX, rowY + 6, controlX + 76, rowY + 24)) {
+ return false;
+ }
+ IdlePlayEntry entry = screen.idlePlayEntries.get(index);
+ if (mouseX < controlX + 24 && entry.priority() > IdlePlayEntry.MIN_PRIORITY) {
+ adjustIdlePlayPriority(entry, -1);
+ } else if (mouseX >= controlX + 26 && mouseX < controlX + 50 && entry.priority() < IdlePlayEntry.MAX_PRIORITY) {
+ adjustIdlePlayPriority(entry, 1);
+ } else if (mouseX >= controlX + 52) {
+ removeIdlePlayEntry(entry);
+ }
+ return true;
+ }
+
+ private void drawScrollbar(GuiGraphicsExtractor context) {
+ int contentHeight = listContentHeight();
+ int viewportHeight = listBottom - listTop;
+ int maxScroll = Math.max(0, contentHeight - viewportHeight);
+ if (maxScroll <= 0) {
+ return;
+ }
+ int x = listX + listW - 5;
+ int trackColor = VpUiRenderer.withAlpha(VpUiRenderer.blend(THEME.panelBorderColor(), THEME.panelBackgroundColor(), 0.55f), 0x88);
+ int thumbColor = VpUiRenderer.withAlpha(VpUiRenderer.blend(THEME.secondaryTextColor(), THEME.accentColor(), 0.35f), 0xDD);
+ int thumbHeight = Math.max(14, viewportHeight * viewportHeight / Math.max(viewportHeight, contentHeight));
+ int thumbTravel = Math.max(1, viewportHeight - thumbHeight);
+ int thumbY = listTop + thumbTravel * Math.clamp(listScroll, 0, maxScroll) / maxScroll;
+ VpUiRenderer.drawBox(context, x, listTop, 4, viewportHeight, trackColor, trackColor);
+ VpUiRenderer.drawBox(context, x, thumbY, 4, thumbHeight, thumbColor, thumbColor);
+ }
+
+ private int listControlX() {
+ return listX + listW - 82;
+ }
+
+ private int maxListScroll() {
+ return Math.max(0, listContentHeight() - Math.max(1, listBottom - listTop));
+ }
+
+ private int listContentHeight() {
+ return screen == null || screen.idlePlayEntries.isEmpty() ? ROW_HEIGHT : screen.idlePlayEntries.size() * ROW_HEIGHT + 8;
+ }
+
+ private void addIdlePlayUrl(VpButtonWidget button) {
+ if (screen == null || urlField == null || priorityField == null) return;
+ String url = VideoUrlNormalizer.normalizeSubmittedUrl(urlField.getValue());
+ if (url.isEmpty()) {
+ sendLocalError(VpTexts.tr("error.videoplayer.idle_play_url_empty", "IdlePlay URL must not be empty"));
+ return;
+ }
+ if (!VideoScreen.validIdlePlayUrl(url)) {
+ sendLocalError(VpTexts.tr("error.videoplayer.idle_play_url_too_long", "IdlePlay URL must not exceed %s UTF-8 bytes", VideoScreen.MAX_IDLE_PLAY_URL_BYTES));
+ return;
+ }
+ if (screen.idlePlayEntries.size() >= VideoScreen.MAX_IDLE_PLAY_ITEMS) {
+ sendLocalError(VpTexts.tr("error.videoplayer.idle_play_too_many", "IdlePlay can contain at most %s entries", VideoScreen.MAX_IDLE_PLAY_ITEMS));
+ return;
+ }
+ int totalBytes = ByteBufUtils.utf8Length(url);
+ for (IdlePlayEntry entry : screen.idlePlayEntries) totalBytes += ByteBufUtils.utf8Length(entry.url());
+ if (totalBytes > VideoScreen.MAX_IDLE_PLAY_TOTAL_BYTES) {
+ sendLocalError(VpTexts.tr("error.videoplayer.idle_play_payload_too_large", "IdlePlay URLs must not exceed %s UTF-8 bytes in total", VideoScreen.MAX_IDLE_PLAY_TOTAL_BYTES));
+ return;
+ }
+ int priority;
+ try {
+ priority = Integer.parseInt(priorityField.getValue().isBlank() ? "0" : priorityField.getValue());
+ } catch (NumberFormatException error) {
+ sendLocalError(VpTexts.tr("error.videoplayer.idle_play_priority_invalid", "Priority must be between 0 and 100"));
+ return;
+ }
+ if (priority < IdlePlayEntry.MIN_PRIORITY || priority > IdlePlayEntry.MAX_PRIORITY) {
+ sendLocalError(VpTexts.tr("error.videoplayer.idle_play_priority_invalid", "Priority must be between 0 and 100"));
+ return;
+ }
+ urlDraft = "";
+ urlField.setValue("");
+ sendIdlePlayMutation(callback -> ClientPacketHandler.addIdlePlay(screen, url, priority, callback), button);
+ }
+
+ private void removeIdlePlayEntry(IdlePlayEntry entry) {
+ if (screen == null || entry == null) return;
+ sendIdlePlayMutation(callback -> ClientPacketHandler.removeIdlePlay(screen, entry.id(), callback), null);
+ }
+
+ private void adjustIdlePlayPriority(IdlePlayEntry entry, int delta) {
+ if (screen == null || entry == null) return;
+ sendIdlePlayMutation(callback -> ClientPacketHandler.adjustIdlePlayPriority(screen, entry.id(), delta, callback), null);
+ }
+
+ private void clearIdlePlay(VpButtonWidget button) {
+ if (screen == null) return;
+ sendIdlePlayMutation(callback -> ClientPacketHandler.clearIdlePlay(screen, callback), button);
+ }
+
+ private void toggleIdlePlayMode(VpButtonWidget button) {
+ if (screen == null) return;
+ sendIdlePlayMutation(callback -> ClientPacketHandler.setIdlePlayMode(screen, !screen.idlePlayRandom, callback), button);
+ }
+
+ private void sendIdlePlayMutation(Consumer> sender, VpButtonWidget button) {
+ if (screen == null || requestPending || !canEditIdlePlay()) return;
+ String currentUrl = urlField == null ? urlDraft : urlField.getValue();
+ urlDraft = currentUrl;
+ requestPending = true;
+ refreshControls();
+ sender.accept(result -> {
+ requestPending = false;
+ if (ClientPacketHandler.denied(result) && button != null) button.showPermissionDenied();
+ if (minecraft != null && minecraft.gui.screen() == this) {
+ listScroll = Math.clamp(listScroll, 0, maxListScroll());
+ rebuildWidgets();
+ }
+ });
+ }
+
+ private void refreshControls() {
+ boolean editable = canEditIdlePlay();
+ if (urlField != null) urlField.active = editable;
+ if (priorityField != null) priorityField.active = editable;
+ if (addButton != null) addButton.active = editable;
+ if (modeButton != null) {
+ modeButton.active = editable;
+ modeButton.setMessage(idlePlayModeText());
+ modeButton.selected(screen != null && screen.idlePlayRandom);
+ }
+ if (clearButton != null) clearButton.active = editable && screen != null && !screen.idlePlayEntries.isEmpty();
+ }
+
+ private Component idlePlayModeText() {
+ Component mode = screen == null || !screen.idlePlayRandom
+ ? VpTexts.tr("label.videoplayer.sequential", "Sequential")
+ : VpTexts.tr("label.videoplayer.random", "Random");
+ return VpTexts.tr("label.videoplayer.mode_value", "Mode: %s", mode.getString());
+ }
+
+ private void sendLocalError(Component message) {
+ if (minecraft != null && minecraft.player != null) {
+ minecraft.player.sendSystemMessage(message.copy().withStyle(ChatFormatting.RED));
+ }
+ }
+
+ private VpButtonWidget button(Component label, int x, int y, int width, Runnable action) {
+ VpButtonWidget button = new VpButtonWidget(x, y, Math.max(34, width), CONTROL_HEIGHT, label, b -> action.run(), THEME);
+ addRenderableWidget(button);
+ return button;
+ }
+
+ private VpButtonWidget button(Component label, int x, int y, int width, Consumer action) {
+ VpButtonWidget button = new VpButtonWidget(x, y, Math.max(34, width), CONTROL_HEIGHT, label, action, THEME);
+ addRenderableWidget(button);
+ return button;
+ }
+
+ private VpButtonWidget button(String label, int x, int y, int width, Runnable action) {
+ VpButtonWidget button = new VpButtonWidget(x, y, Math.max(34, width), CONTROL_HEIGHT, Component.literal(label), b -> action.run(), THEME);
+ addRenderableWidget(button);
+ return button;
+ }
+
+ private VpButtonWidget button(String label, int x, int y, int width, Consumer action) {
+ VpButtonWidget button = new VpButtonWidget(x, y, Math.max(34, width), CONTROL_HEIGHT, Component.literal(label), action, THEME);
+ addRenderableWidget(button);
+ return button;
+ }
+
+ private boolean canEditIdlePlay() {
+ return screen != null
+ && !requestPending
+ && screen.area != null
+ && screen.area.getScreen(screen.name) == screen
+ && ClientPermissionCache.allowedOrUnknown(VideoPermissionAction.SET_IDLE_PLAY, screen);
+ }
+
+ private void drawLabel(GuiGraphicsExtractor context, String label, int x, int y, int color) {
+ drawLabel(context, Component.literal(label), x, y, color);
+ }
+
+ private void drawLabel(GuiGraphicsExtractor context, Component label, int x, int y, int color) {
+ if (THEME.textShadow()) {
+ context.text(font, label, x, y, color);
+ return;
+ }
+ context.text(font, label, x, y, color, false);
+ }
+
+ private void drawCenteredText(GuiGraphicsExtractor context, Component text, int centerX, int y, int color) {
+ int x = centerX - font.width(text) / 2;
+ drawLabel(context, text, x, y, color);
+ }
+
+ private String trimToWidth(String text, int maxWidth) {
+ String value = text == null ? "" : text;
+ if (font.width(value) <= maxWidth) return value;
+ String suffix = "...";
+ return font.plainSubstrByWidth(value, Math.max(0, maxWidth - font.width(suffix))) + suffix;
+ }
+
+ private boolean inside(double mouseX, double mouseY, int left, int top, int right, int bottom) {
+ return mouseX >= left && mouseY >= top && mouseX < right && mouseY < bottom;
+ }
+}
diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/MpvFilterGraphScreen.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/MpvFilterGraphScreen.java
new file mode 100644
index 0000000..dec766b
--- /dev/null
+++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/MpvFilterGraphScreen.java
@@ -0,0 +1,242 @@
+package com.github.squi2rel.vp.creation;
+
+import com.github.squi2rel.mcng.core.GraphDocument;
+import com.github.squi2rel.mcng.core.GraphJsonCodec;
+import com.github.squi2rel.mcng.fabric.client.GraphEditorBounds;
+import com.github.squi2rel.mcng.fabric.client.GraphEditorComponent;
+import com.github.squi2rel.mcng.fabric.client.GraphEditorHost;
+import com.github.squi2rel.mcng.fabric.client.GraphEditorI18n;
+import com.github.squi2rel.mcng.fabric.client.GraphEditorSession;
+import com.github.squi2rel.mcng.fabric.client.GraphEditorTheme;
+import com.github.squi2rel.mcng.fabric.client.GraphEditorUiConfig;
+import com.github.squi2rel.mcng.fabric.client.NodeComponentRegistry;
+import com.github.squi2rel.vp.filtergraph.MpvFilterGraphCompiler;
+import com.github.squi2rel.vp.filtergraph.MpvFilterGraphManager;
+import com.github.squi2rel.vp.filtergraph.MpvFilterGraphNodes;
+import com.github.squi2rel.vp.filtergraph.MpvFilterGraphTypes;
+import com.github.squi2rel.vp.filtergraph.MpvLavfiFilterCatalog;
+import com.github.squi2rel.vp.i18n.VpTexts;
+import net.minecraft.client.gui.GuiGraphicsExtractor;
+import net.minecraft.client.gui.screens.Screen;
+import net.minecraft.client.input.CharacterEvent;
+import net.minecraft.client.input.KeyEvent;
+import net.minecraft.client.input.MouseButtonEvent;
+import net.minecraft.client.resources.language.I18n;
+import net.minecraft.locale.Language;
+import net.minecraft.network.chat.Component;
+import org.lwjgl.glfw.GLFW;
+
+public class MpvFilterGraphScreen extends Screen implements GraphEditorHost {
+ private static final VpUiTheme THEME = VpUiTheme.classic();
+ private static final int TOP_BAR_HEIGHT = 28;
+ private static final long AUTO_APPLY_DELAY_MS = 500L;
+ private static final GraphEditorI18n MINECRAFT_I18N = (key, fallback, args) ->
+ Language.getInstance().has(key) ? I18n.get(key, args) : GraphEditorI18n.formatFallback(fallback, key, args);
+
+ private final Screen parent;
+ private final GraphJsonCodec codec = new GraphJsonCodec();
+ private final GraphEditorSession session;
+ private final GraphEditorComponent editor;
+
+ private VpButtonWidget applyButton;
+ private VpButtonWidget autoApplyButton;
+ private String status = "";
+ private boolean statusError;
+ private long autoApplyAt = -1L;
+
+ public MpvFilterGraphScreen(Screen parent) {
+ super(VpTexts.tr("screen.videoplayer.mpv_filter_graph", "MPV Filter Graph"));
+ this.parent = parent;
+ GraphDocument document = MpvFilterGraphManager.document();
+ this.session = new GraphEditorSession(
+ MpvFilterGraphNodes.createRegistry(),
+ MpvFilterGraphTypes.createRegistry(),
+ codec,
+ document,
+ this
+ );
+ this.editor = new GraphEditorComponent(
+ session,
+ MpvFilterGraphNodes.createPalette(),
+ new NodeComponentRegistry(),
+ GraphEditorUiConfig.defaultConfig().withTheme(GraphEditorTheme.classic())
+ );
+ }
+
+ @Override
+ protected void init() {
+ applyButton = new VpButtonWidget(width - 176, 5, 72, 18,
+ VpTexts.tr("button.videoplayer.apply_filter", "Apply"), button -> applyNow(), THEME);
+ autoApplyButton = new VpButtonWidget(width - 96, 5, 88, 18, autoApplyText(), button -> toggleAutoApply(), THEME)
+ .selected(MpvFilterGraphManager.autoApply());
+ addRenderableWidget(applyButton);
+ addRenderableWidget(autoApplyButton);
+ editor.init(font, editorBounds());
+ syncStatusFromCompile();
+ }
+
+ @Override
+ public void tick() {
+ super.tick();
+ if (autoApplyAt > 0 && System.currentTimeMillis() >= autoApplyAt) {
+ autoApplyAt = -1L;
+ applyNow();
+ }
+ }
+
+ @Override
+ public void extractRenderState(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
+ editor.setBounds(editorBounds());
+ editor.render(context, font, mouseX, mouseY, delta);
+ context.fill(0, 0, width, TOP_BAR_HEIGHT, THEME.panelBackgroundColor());
+ context.text(font, title, 8, 10, THEME.primaryTextColor());
+ int statusRight = Math.max(80, width - 184);
+ String visible = font.plainSubstrByWidth(status == null ? "" : status, statusRight - 90);
+ context.text(font, Component.literal(visible), 90, 10, statusColor());
+ super.extractRenderState(context, mouseX, mouseY, delta);
+ }
+
+ @Override
+ public boolean mouseClicked(MouseButtonEvent click, boolean doubleClick) {
+ if (super.mouseClicked(click, doubleClick)) return true;
+ return editor.mouseClicked(click.x(), click.y(), click.button());
+ }
+
+ @Override
+ public boolean mouseDragged(MouseButtonEvent click, double deltaX, double deltaY) {
+ return editor.mouseDragged(click.x(), click.y(), click.button(), deltaX, deltaY) || super.mouseDragged(click, deltaX, deltaY);
+ }
+
+ @Override
+ public boolean mouseReleased(MouseButtonEvent click) {
+ return editor.mouseReleased(click.x(), click.y(), click.button()) || super.mouseReleased(click);
+ }
+
+ @Override
+ public boolean mouseScrolled(double mouseX, double mouseY, double horizontalAmount, double verticalAmount) {
+ return editor.mouseScrolled(mouseX, mouseY, horizontalAmount, verticalAmount) || super.mouseScrolled(mouseX, mouseY, horizontalAmount, verticalAmount);
+ }
+
+ @Override
+ public boolean keyPressed(KeyEvent input) {
+ if (editor.keyPressed(input.key(), input.scancode(), input.modifiers())) return true;
+ if (input.key() == GLFW.GLFW_KEY_DELETE || input.key() == GLFW.GLFW_KEY_BACKSPACE) {
+ session.removeSelectedNodes();
+ return true;
+ }
+ return super.keyPressed(input);
+ }
+
+ @Override
+ public boolean charTyped(CharacterEvent input) {
+ if (input.isAllowedChatCharacter()) {
+ String value = input.codepointAsString();
+ if (value.length() == 1 && editor.charTyped(value.charAt(0), 0)) return true;
+ }
+ return super.charTyped(input);
+ }
+
+ @Override
+ public void onClose() {
+ editor.close();
+ if (minecraft != null) minecraft.gui.setScreen(parent);
+ }
+
+ @Override
+ public boolean isPauseScreen() {
+ return false;
+ }
+
+ @Override
+ public void onDocumentChanged(GraphDocument document) {
+ MpvFilterGraphManager.setDocument(document);
+ syncStatusFromCompile();
+ if (MpvFilterGraphManager.autoApply()) {
+ autoApplyAt = System.currentTimeMillis() + AUTO_APPLY_DELAY_MS;
+ }
+ }
+
+ @Override
+ public void copyToClipboard(String value) {
+ if (minecraft != null) minecraft.keyboardHandler.setClipboard(value);
+ }
+
+ @Override
+ public String readClipboard() {
+ return minecraft == null ? "" : minecraft.keyboardHandler.getClipboard();
+ }
+
+ @Override
+ public void showMessage(String message) {
+ status = message == null ? "" : message;
+ statusError = false;
+ }
+
+ @Override
+ public GraphEditorI18n i18n() {
+ return MINECRAFT_I18N;
+ }
+
+ private void toggleAutoApply() {
+ MpvFilterGraphManager.setAutoApply(!MpvFilterGraphManager.autoApply());
+ autoApplyAt = -1L;
+ if (autoApplyButton != null) {
+ autoApplyButton.setMessage(autoApplyText());
+ autoApplyButton.selected(MpvFilterGraphManager.autoApply());
+ }
+ setStatus(
+ MpvFilterGraphManager.autoApply() ? "message.videoplayer.mpv_auto_apply_enabled" : "message.videoplayer.mpv_auto_apply_disabled",
+ MpvFilterGraphManager.autoApply() ? "Automatic filter application enabled" : "Automatic filter application disabled"
+ );
+ }
+
+ private void applyNow() {
+ MpvFilterGraphManager.ApplyResult result = MpvFilterGraphManager.applyToActivePlayers();
+ if (result.success()) {
+ setStatus("message.videoplayer.mpv_filter_applied", "%1$s (%2$s active players)", result.message(), result.playerCount());
+ } else {
+ setStatus("error.videoplayer.mpv_filter_apply_failed", "MPV filter graph could not be applied: %s", result.message());
+ }
+ }
+
+ private void syncStatusFromCompile() {
+ MpvLavfiFilterCatalog.Catalog catalog = MpvLavfiFilterCatalog.get();
+ if (!catalog.usable()) {
+ if (catalog.available()) {
+ setStatus("error.videoplayer.mpv_filter_api_no_filters", "MPV filter API returned no lavfi filters.");
+ } else {
+ setStatus("error.videoplayer.mpv_filter_api_unavailable", "MPV filter API unavailable: %s", catalog.error());
+ }
+ return;
+ }
+ MpvFilterGraphCompiler.CompileResult compiled = MpvFilterGraphManager.compileCurrent();
+ if (compiled.success()) {
+ setStatus(
+ compiled.graph().isBlank() ? "message.videoplayer.mpv_filter_saved_empty" : "message.videoplayer.mpv_filter_saved_ready",
+ compiled.graph().isBlank() ? "Saved. No active graph." : "Saved. Graph ready."
+ );
+ } else {
+ setStatus("error.videoplayer.mpv_filter_compile", "Filter graph compile error: %s", compiled.error());
+ }
+ }
+
+ private int statusColor() {
+ return statusError ? THEME.errorColor() : THEME.secondaryTextColor();
+ }
+
+ private Component autoApplyText() {
+ return VpTexts.tr("label.videoplayer.mpv_auto_apply", "Auto: %s",
+ MpvFilterGraphManager.autoApply()
+ ? VpTexts.tr("label.videoplayer.on", "On")
+ : VpTexts.tr("label.videoplayer.off", "Off"));
+ }
+
+ private void setStatus(String key, String fallback, Object... args) {
+ status = VpTexts.tr(key, fallback, args).getString();
+ statusError = key.startsWith("error.");
+ }
+
+ private GraphEditorBounds editorBounds() {
+ return new GraphEditorBounds(0, TOP_BAR_HEIGHT, width, Math.max(1, height - TOP_BAR_HEIGHT));
+ }
+}
diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/SelectionPreviewRenderer.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/SelectionPreviewRenderer.java
new file mode 100644
index 0000000..aadfced
--- /dev/null
+++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/SelectionPreviewRenderer.java
@@ -0,0 +1,502 @@
+package com.github.squi2rel.vp.creation;
+
+import com.github.squi2rel.vp.ScreenRenderer;
+import com.github.squi2rel.vp.VideoPlayerClient;
+import com.github.squi2rel.vp.i18n.VpTexts;
+import com.github.squi2rel.vp.render.WorldRenderBatch;
+import com.github.squi2rel.vp.video.ClientVideoArea;
+import com.github.squi2rel.vp.video.ScreenGeometry;
+import com.github.squi2rel.vp.video.VideoScreen;
+import com.mojang.blaze3d.vertex.PoseStack;
+import com.mojang.blaze3d.vertex.VertexConsumer;
+import net.minecraft.client.DeltaTracker;
+import net.minecraft.client.Camera;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.GuiGraphicsExtractor;
+import net.minecraft.client.renderer.rendertype.RenderType;
+import net.minecraft.client.renderer.rendertype.RenderTypes;
+import net.minecraft.network.chat.Component;
+import net.minecraft.world.phys.AABB;
+import net.minecraft.world.phys.Vec3;
+import org.joml.Matrix4f;
+import org.joml.Vector2f;
+import org.joml.Vector3f;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+public final class SelectionPreviewRenderer {
+ private static final int SCREEN_COLOR = 0xFF50A0FF;
+ private static final int PREVIEW_COLOR = 0xFFFFD050;
+ private static final int POINT_COLOR = 0xFFFF7050;
+ private static final int SELECTED_POINT_COLOR = 0xFFFFFFFF;
+ private static final int AXIS_X_COLOR = 0xFFFF4040;
+ private static final int AXIS_Y_COLOR = 0xFF45E06F;
+ private static final int AXIS_Z_COLOR = 0xFF5090FF;
+ private static final int AXIS_HOT_COLOR = 0xFFFFFF80;
+ private static final int PREVIEW_ALPHA = 120;
+
+ private SelectionPreviewRenderer() {
+ }
+
+ public static void extractWorld(WorldRenderBatch consumers, Camera cameraObject) {
+ VideoCreationEditor editor = VideoCreationEditor.instance();
+ if (!editor.active()) return;
+ PoseStack matrices = new PoseStack();
+ Vec3 camera = cameraObject.position();
+ matrices.pushPose();
+
+ drawScreenPreviewTexture(editor, matrices, consumers, camera);
+
+ VertexConsumer consumer = consumers.getBuffer(RenderTypes.lines());
+ drawExistingAreas(matrices, consumer, camera);
+ drawExistingScreens(editor, matrices, consumer, camera);
+ drawAreaPreview(editor, matrices, consumer, camera);
+ drawScreenPreview(editor, matrices, consumer, camera);
+ drawSelectionPoints(editor, matrices, consumer, camera);
+ drawGizmo(editor, matrices, consumer, camera);
+
+ matrices.popPose();
+ }
+
+ public static void renderHud(GuiGraphicsExtractor context, DeltaTracker tickCounter) {
+ VideoCreationEditor editor = VideoCreationEditor.instance();
+ if (!editor.selecting()) return;
+
+ Minecraft client = Minecraft.getInstance();
+ int x = context.guiWidth() / 2 + 12;
+ int y = context.guiHeight() / 2 + 12;
+ int color = editor.statusError() ? 0xFFFF5555 : 0xFFFFFFFF;
+ context.text(client.font, editor.modeText(), x, y, 0xFFFFD050);
+ context.text(client.font, VpTexts.tr("label.videoplayer.point_progress", "Points %s", editor.pointProgress()), x, y + 11, 0xFFE0E0E0);
+ context.text(client.font, editor.status(), x, y + 22, color);
+ VideoCreationEditor.SelectionPoint selected = editor.selectedPoint();
+ if (editor.screenGizmoVisible() && selected != null) {
+ context.text(client.font, VpTexts.tr("label.videoplayer.selected_point", "Selected %s: %s", editor.selectedPointIndex() + 1, selected.format()), x, y + 33, 0xFFB0B0B0);
+ } else if (editor.showCurrentTargetPoint()) {
+ VideoCreationEditor.SelectionPoint target = editor.currentTargetPoint();
+ if (target == null) return;
+ context.text(client.font, Component.literal(target.format()), x, y + 33, 0xFFB0B0B0);
+ }
+ }
+
+ private static void drawExistingAreas(PoseStack matrices, VertexConsumer consumer, Vec3 camera) {
+ for (ClientVideoArea area : VideoPlayerClient.areas.values()) {
+ drawBox(
+ matrices,
+ consumer,
+ area.min.x, area.min.y, area.min.z,
+ area.max.x, area.max.y, area.max.z,
+ 0xB32ED180,
+ camera
+ );
+ }
+ }
+
+ private static void drawExistingScreens(VideoCreationEditor editor, PoseStack matrices, VertexConsumer consumer, Vec3 camera) {
+ for (ClientVideoArea area : VideoPlayerClient.areas.values()) {
+ for (VideoScreen screen : area.screens) {
+ if (screen.vertices != null && screen.vertices.size() >= ScreenGeometry.MIN_VERTICES) {
+ drawPolygon(matrices, consumer, screen.vertices, SCREEN_COLOR, camera);
+ }
+ if (editor.selectingSpherePreset() && screen.spherePreset) {
+ drawSphere(matrices, consumer, screen.sphereCenter, screen.sphereRadius, SCREEN_COLOR, screen.stereo3d, camera);
+ }
+ }
+ }
+ }
+
+ private static void drawAreaPreview(VideoCreationEditor editor, PoseStack matrices, VertexConsumer consumer, Vec3 camera) {
+ AABB box = editor.areaPreview();
+ if (box == null) return;
+ drawBox(
+ matrices,
+ consumer,
+ box.minX, box.minY, box.minZ,
+ box.maxX, box.maxY, box.maxZ,
+ PREVIEW_COLOR,
+ camera
+ );
+ }
+
+ private static void drawScreenPreviewTexture(VideoCreationEditor editor, PoseStack matrices, WorldRenderBatch consumers, Vec3 camera) {
+ if (editor.selectingSpherePreset()) return;
+ List vertices = editor.previewVertices();
+ if (vertices != null) {
+ drawPlaceholderPreview(matrices, consumers, vertices, camera);
+ }
+ }
+
+ private static void drawScreenPreview(VideoCreationEditor editor, PoseStack matrices, VertexConsumer consumer, Vec3 camera) {
+ Vector3f center = editor.spherePreviewCenter();
+ float radius = editor.spherePreviewRadius();
+ if (center != null && radius > ScreenGeometry.EPSILON) {
+ drawSphere(matrices, consumer, center, radius, PREVIEW_COLOR, editor.draft().stereo3d, camera);
+ }
+ if (editor.selectingSpherePreset()) {
+ return;
+ }
+ List vertices = editor.previewVertices();
+ if (vertices != null) {
+ drawPolygon(matrices, consumer, vertices, PREVIEW_COLOR, camera);
+ return;
+ }
+ if (editor.draft().target != VideoCreationEditor.Target.SCREEN) return;
+ if (editor.points().isEmpty()) return;
+
+ Matrix4f matrix = matrices.last().pose();
+ for (int i = 1; i < editor.points().size(); i++) {
+ drawWorldLine(matrix, consumer, editor.points().get(i - 1).point, editor.points().get(i).point, PREVIEW_COLOR, camera);
+ }
+ VideoCreationEditor.SelectionPoint current = editor.showCurrentTargetPoint() ? editor.currentTargetPoint() : null;
+ if (current != null) {
+ drawWorldLine(matrix, consumer, editor.points().getLast().point, current.point, PREVIEW_COLOR, camera);
+ }
+ }
+
+ private static void drawSelectionPoints(VideoCreationEditor editor, PoseStack matrices, VertexConsumer consumer, Vec3 camera) {
+ for (int i = 0; i < editor.points().size(); i++) {
+ VideoCreationEditor.SelectionPoint point = editor.points().get(i);
+ drawPoint(matrices, consumer, point.point, i == editor.selectedPointIndex() ? SELECTED_POINT_COLOR : POINT_COLOR, camera);
+ }
+ if (editor.selecting() && editor.showCurrentTargetPoint()) {
+ VideoCreationEditor.SelectionPoint current = editor.currentTargetPoint();
+ if (current != null) drawPoint(matrices, consumer, current.point, PREVIEW_COLOR, camera);
+ }
+ }
+
+ private static void drawGizmo(VideoCreationEditor editor, PoseStack matrices, VertexConsumer consumer, Vec3 camera) {
+ if (!editor.screenGizmoVisible()) return;
+ VideoCreationEditor.SelectionPoint selected = editor.selectedPoint();
+ if (selected == null) return;
+
+ Matrix4f matrix = matrices.last().pose();
+ for (VideoCreationEditor.GizmoAxis axis : VideoCreationEditor.GizmoAxis.values()) {
+ int color = axisColor(editor, axis);
+ drawAxis(matrix, consumer, selected.point, axis, editor.gizmoStart(), editor.gizmoLength(), color, camera);
+ }
+ }
+
+ private static int axisColor(VideoCreationEditor editor, VideoCreationEditor.GizmoAxis axis) {
+ if (axis == editor.draggingAxis() || axis == editor.hoveredAxis()) return AXIS_HOT_COLOR;
+ return switch (axis) {
+ case X -> AXIS_X_COLOR;
+ case Y -> AXIS_Y_COLOR;
+ case Z -> AXIS_Z_COLOR;
+ };
+ }
+
+ private static void drawAxis(Matrix4f matrix, VertexConsumer consumer, Vector3f origin,
+ VideoCreationEditor.GizmoAxis axis, float startDistance, float length, int color, Vec3 camera) {
+ Vector3f axisVector = axis.vector();
+ Vector3f start = new Vector3f(origin).add(new Vector3f(axisVector).mul(startDistance));
+ Vector3f end = new Vector3f(origin).add(new Vector3f(axisVector).mul(length));
+ drawWorldLine(matrix, consumer, start, end, color, camera);
+
+ float headLength = 0.12f;
+ float headWidth = 0.045f;
+ Vector3f base = new Vector3f(end).sub(new Vector3f(axisVector).mul(headLength));
+ Vector3f sideA = arrowSide(axis, true).mul(headWidth);
+ Vector3f sideB = arrowSide(axis, false).mul(headWidth);
+ drawWorldLine(matrix, consumer, end, new Vector3f(base).add(sideA), color, camera);
+ drawWorldLine(matrix, consumer, end, new Vector3f(base).sub(sideA), color, camera);
+ drawWorldLine(matrix, consumer, end, new Vector3f(base).add(sideB), color, camera);
+ drawWorldLine(matrix, consumer, end, new Vector3f(base).sub(sideB), color, camera);
+ }
+
+ private static Vector3f arrowSide(VideoCreationEditor.GizmoAxis axis, boolean first) {
+ return switch (axis) {
+ case X -> first ? new Vector3f(0, 1, 0) : new Vector3f(0, 0, 1);
+ case Y -> first ? new Vector3f(1, 0, 0) : new Vector3f(0, 0, 1);
+ case Z -> first ? new Vector3f(1, 0, 0) : new Vector3f(0, 1, 0);
+ };
+ }
+
+ private static void drawPoint(PoseStack matrices, VertexConsumer consumer, Vector3f point, int color, Vec3 camera) {
+ float size = 0.045f;
+ drawBox(
+ matrices,
+ consumer,
+ point.x - size, point.y - size, point.z - size,
+ point.x + size, point.y + size, point.z + size,
+ color,
+ camera
+ );
+ }
+
+ private static void drawBox(PoseStack matrices, VertexConsumer consumer,
+ double minX, double minY, double minZ,
+ double maxX, double maxY, double maxZ,
+ int color, Vec3 camera) {
+ Matrix4f matrix = matrices.last().pose();
+ float relativeMinX = (float) (minX - camera.x);
+ float relativeMinY = (float) (minY - camera.y);
+ float relativeMinZ = (float) (minZ - camera.z);
+ float relativeMaxX = (float) (maxX - camera.x);
+ float relativeMaxY = (float) (maxY - camera.y);
+ float relativeMaxZ = (float) (maxZ - camera.z);
+ Vector3f p000 = new Vector3f(relativeMinX, relativeMinY, relativeMinZ);
+ Vector3f p001 = new Vector3f(relativeMinX, relativeMinY, relativeMaxZ);
+ Vector3f p010 = new Vector3f(relativeMinX, relativeMaxY, relativeMinZ);
+ Vector3f p011 = new Vector3f(relativeMinX, relativeMaxY, relativeMaxZ);
+ Vector3f p100 = new Vector3f(relativeMaxX, relativeMinY, relativeMinZ);
+ Vector3f p101 = new Vector3f(relativeMaxX, relativeMinY, relativeMaxZ);
+ Vector3f p110 = new Vector3f(relativeMaxX, relativeMaxY, relativeMinZ);
+ Vector3f p111 = new Vector3f(relativeMaxX, relativeMaxY, relativeMaxZ);
+ drawLine(matrix, consumer, p000, p001, color);
+ drawLine(matrix, consumer, p001, p101, color);
+ drawLine(matrix, consumer, p101, p100, color);
+ drawLine(matrix, consumer, p100, p000, color);
+ drawLine(matrix, consumer, p010, p011, color);
+ drawLine(matrix, consumer, p011, p111, color);
+ drawLine(matrix, consumer, p111, p110, color);
+ drawLine(matrix, consumer, p110, p010, color);
+ drawLine(matrix, consumer, p000, p010, color);
+ drawLine(matrix, consumer, p001, p011, color);
+ drawLine(matrix, consumer, p101, p111, color);
+ drawLine(matrix, consumer, p100, p110, color);
+ }
+
+ private static void drawPolygon(PoseStack matrices, VertexConsumer consumer, List vertices, int color, Vec3 camera) {
+ if (vertices == null || vertices.size() < 2) return;
+ Matrix4f matrix = matrices.last().pose();
+ for (int i = 0; i < vertices.size(); i++) {
+ drawWorldLine(matrix, consumer, vertices.get(i), vertices.get((i + 1) % vertices.size()), color, camera);
+ }
+ try {
+ ScreenGeometry geometry = ScreenGeometry.create(vertices);
+ Vector3f relativeOrigin = geometry.relativeOrigin(camera.x, camera.y, camera.z);
+ matrices.pushPose();
+ matrices.translate(relativeOrigin.x, relativeOrigin.y, relativeOrigin.z);
+ drawTriangleEdges(matrices.last().pose(), consumer, geometry.localVertices(), geometry.triangles(), color);
+ matrices.popPose();
+ } catch (IllegalArgumentException ignored) {
+ }
+ }
+
+ private static void drawTriangleEdges(Matrix4f matrix, VertexConsumer consumer, List vertices, int[] triangles, int color) {
+ Set drawn = new HashSet<>();
+ for (int i = 0; i < triangles.length; i += 3) {
+ drawTriangleEdge(matrix, consumer, vertices, triangles[i], triangles[i + 1], color, drawn);
+ drawTriangleEdge(matrix, consumer, vertices, triangles[i + 1], triangles[i + 2], color, drawn);
+ drawTriangleEdge(matrix, consumer, vertices, triangles[i + 2], triangles[i], color, drawn);
+ }
+ }
+
+ private static void drawTriangleEdge(Matrix4f matrix, VertexConsumer consumer, List vertices,
+ int from, int to, int color, Set drawn) {
+ int size = vertices.size();
+ int diff = Math.abs(from - to);
+ if (diff == 1 || diff == size - 1) return;
+ int min = Math.min(from, to);
+ int max = Math.max(from, to);
+ long key = ((long) min << 32) | max;
+ if (!drawn.add(key)) return;
+ drawLine(matrix, consumer, vertices.get(from), vertices.get(to), color);
+ }
+
+ private static void drawLine(Matrix4f matrix, VertexConsumer consumer, Vector3f from, Vector3f to, int color) {
+ Vector3f normal = new Vector3f(to).sub(from);
+ if (normal.lengthSquared() == 0) return;
+ normal.normalize();
+ consumer.addVertex(matrix, from.x, from.y, from.z).setColor(color).setNormal(normal.x, normal.y, normal.z).setLineWidth(1.0f);
+ consumer.addVertex(matrix, to.x, to.y, to.z).setColor(color).setNormal(normal.x, normal.y, normal.z).setLineWidth(1.0f);
+ }
+
+ private static void drawWorldLine(Matrix4f matrix, VertexConsumer consumer, Vector3f from, Vector3f to, int color, Vec3 camera) {
+ drawLine(matrix, consumer, relative(from, camera), relative(to, camera), color);
+ }
+
+ private static Vector3f relative(Vector3f point, Vec3 camera) {
+ return new Vector3f(
+ (float) (point.x - camera.x),
+ (float) (point.y - camera.y),
+ (float) (point.z - camera.z)
+ );
+ }
+
+ private static void drawSphere(PoseStack matrices, VertexConsumer consumer, Vector3f center, float radius, int color, boolean hemisphere, Vec3 camera) {
+ if (center == null || !Float.isFinite(radius) || radius <= 0) return;
+ Vector3f relativeCenter = relative(center, camera);
+ matrices.pushPose();
+ matrices.translate(relativeCenter.x, relativeCenter.y, relativeCenter.z);
+ if (hemisphere) {
+ drawHemisphere(matrices, consumer, radius, color);
+ matrices.popPose();
+ return;
+ }
+ Matrix4f matrix = matrices.last().pose();
+ int segments = 48;
+ for (int i = 0; i < segments; i++) {
+ float a = (float) (Math.PI * 2 * i / segments);
+ float b = (float) (Math.PI * 2 * (i + 1) / segments);
+ drawLine(matrix, consumer,
+ new Vector3f((float) Math.cos(a) * radius, 0, (float) Math.sin(a) * radius),
+ new Vector3f((float) Math.cos(b) * radius, 0, (float) Math.sin(b) * radius),
+ color);
+ drawLine(matrix, consumer,
+ new Vector3f((float) Math.cos(a) * radius, (float) Math.sin(a) * radius, 0),
+ new Vector3f((float) Math.cos(b) * radius, (float) Math.sin(b) * radius, 0),
+ color);
+ drawLine(matrix, consumer,
+ new Vector3f(0, (float) Math.cos(a) * radius, (float) Math.sin(a) * radius),
+ new Vector3f(0, (float) Math.cos(b) * radius, (float) Math.sin(b) * radius),
+ color);
+ }
+ matrices.popPose();
+ }
+
+ private static void drawHemisphere(PoseStack matrices, VertexConsumer consumer, float radius, int color) {
+ Matrix4f matrix = matrices.last().pose();
+ int segments = 48;
+ for (int i = 0; i < segments; i++) {
+ float a = (float) (Math.PI * i / segments);
+ float b = (float) (Math.PI * (i + 1) / segments);
+ drawLine(matrix, consumer,
+ new Vector3f((float) Math.cos(a) * radius, 0, (float) Math.sin(a) * radius),
+ new Vector3f((float) Math.cos(b) * radius, 0, (float) Math.sin(b) * radius),
+ color);
+ drawLine(matrix, consumer,
+ new Vector3f(0, (float) Math.cos(a) * radius, (float) Math.sin(a) * radius),
+ new Vector3f(0, (float) Math.cos(b) * radius, (float) Math.sin(b) * radius),
+ color);
+
+ float rimA = (float) (Math.PI * 2 * i / segments);
+ float rimB = (float) (Math.PI * 2 * (i + 1) / segments);
+ drawLine(matrix, consumer,
+ new Vector3f((float) Math.cos(rimA) * radius, (float) Math.sin(rimA) * radius, 0),
+ new Vector3f((float) Math.cos(rimB) * radius, (float) Math.sin(rimB) * radius, 0),
+ color);
+ }
+ }
+
+ private static void drawPlaceholderPreview(PoseStack matrices, WorldRenderBatch consumers, List vertices, Vec3 camera) {
+ if (vertices == null || vertices.size() < ScreenGeometry.MIN_VERTICES) return;
+ ScreenGeometry geometry;
+ try {
+ geometry = ScreenGeometry.create(vertices);
+ } catch (IllegalArgumentException ignored) {
+ return;
+ }
+
+ int previewTextureId = ScreenRenderer.placeholderTextureId();
+ Vector3f relativeOrigin = geometry.relativeOrigin(camera.x, camera.y, camera.z);
+ matrices.pushPose();
+ matrices.translate(relativeOrigin.x, relativeOrigin.y, relativeOrigin.z);
+ Matrix4f matrix = matrices.last().pose();
+ float[] bounds = geometry.contentBounds(0, 0, 1, 1, false, 1, 1, 960, 540);
+ int[] triangles = geometry.triangles();
+ List geometryVertices = geometry.localVertices();
+ Vector3f normal = geometry.normal();
+ VertexConsumer backingConsumer = consumers.getBuffer(ScreenRenderer.getBackingLayer(previewTextureId));
+ for (int i = 0; i < triangles.length; i += 3) {
+ drawPreviewTriangle(matrix, backingConsumer, geometry, geometryVertices, triangles, i, bounds, normal, PREVIEW_ALPHA << 24);
+ }
+
+ RenderType layer = ScreenRenderer.getTranslucentLayer(previewTextureId);
+ VertexConsumer textureConsumer = consumers.getBuffer(layer);
+ for (int i = 0; i < triangles.length; i += 3) {
+ drawPreviewTriangle(matrix, textureConsumer, geometry, geometryVertices, triangles, i, bounds, normal, (PREVIEW_ALPHA << 24) | 0x00FFFFFF);
+ }
+ matrices.popPose();
+ }
+
+ private static void drawPreviewTriangle(Matrix4f matrix, VertexConsumer consumer, ScreenGeometry geometry,
+ List vertices, int[] triangles, int offset, float[] bounds, Vector3f normal,
+ int color) {
+ ArrayList polygon = new ArrayList<>(3);
+ addProjectedVertex(polygon, geometry, vertices, triangles[offset]);
+ addProjectedVertex(polygon, geometry, vertices, triangles[offset + 1]);
+ addProjectedVertex(polygon, geometry, vertices, triangles[offset + 2]);
+ polygon = clipPolygon(polygon, bounds);
+ if (polygon.size() < 3) return;
+
+ ProjectedVertex first = polygon.getFirst();
+ for (int i = 1; i < polygon.size() - 1; i++) {
+ drawPreviewVertex(matrix, consumer, geometry, first, bounds, normal, color);
+ drawPreviewVertex(matrix, consumer, geometry, polygon.get(i), bounds, normal, color);
+ drawPreviewVertex(matrix, consumer, geometry, polygon.get(i + 1), bounds, normal, color);
+ drawPreviewVertex(matrix, consumer, geometry, polygon.get(i + 1), bounds, normal, color);
+ drawPreviewVertex(matrix, consumer, geometry, first, bounds, normal, color);
+ drawPreviewVertex(matrix, consumer, geometry, polygon.get(i + 1), bounds, normal, color);
+ drawPreviewVertex(matrix, consumer, geometry, polygon.get(i), bounds, normal, color);
+ drawPreviewVertex(matrix, consumer, geometry, polygon.get(i), bounds, normal, color);
+ }
+ }
+
+ private static void addProjectedVertex(ArrayList polygon, ScreenGeometry geometry, List vertices, int index) {
+ polygon.add(new ProjectedVertex(geometry.projectedPoint(index), geometry.editPoint(index), new Vector3f(vertices.get(index))));
+ }
+
+ private static void drawPreviewVertex(Matrix4f matrix, VertexConsumer consumer, ScreenGeometry geometry,
+ ProjectedVertex projected, float[] bounds, Vector3f normal, int color) {
+ Vector2f uv = geometry.textureCoord(projected.texturePoint.x, projected.texturePoint.y, bounds, 0, 0, 1, 1);
+ Vector3f vertex = projected.vertex;
+ ScreenRenderer.drawWorldTexturedVertex(matrix, consumer, vertex, uv.x, uv.y, color, normal);
+ }
+
+ private static ArrayList clipPolygon(ArrayList polygon, float[] bounds) {
+ polygon = clip(polygon, bounds[0], true, true);
+ polygon = clip(polygon, bounds[1], true, false);
+ polygon = clip(polygon, bounds[2], false, true);
+ polygon = clip(polygon, bounds[3], false, false);
+ return polygon;
+ }
+
+ private static ArrayList clip(ArrayList input, float limit, boolean axisU, boolean keepGreater) {
+ ArrayList output = new ArrayList<>();
+ if (input.isEmpty()) return output;
+
+ ProjectedVertex previous = input.getLast();
+ boolean previousInside = inside(previous, limit, axisU, keepGreater);
+ for (ProjectedVertex current : input) {
+ boolean currentInside = inside(current, limit, axisU, keepGreater);
+ if (currentInside != previousInside) {
+ output.add(intersection(previous, current, limit, axisU));
+ }
+ if (currentInside) {
+ output.add(current.copy());
+ }
+ previous = current;
+ previousInside = currentInside;
+ }
+ return output;
+ }
+
+ private static boolean inside(ProjectedVertex projected, float limit, boolean axisU, boolean keepGreater) {
+ float value = axisU ? projected.texturePoint.x : projected.texturePoint.y;
+ return keepGreater ? value >= limit - ScreenGeometry.EPSILON : value <= limit + ScreenGeometry.EPSILON;
+ }
+
+ private static ProjectedVertex intersection(ProjectedVertex from, ProjectedVertex to, float limit, boolean axisU) {
+ float start = axisU ? from.texturePoint.x : from.texturePoint.y;
+ float end = axisU ? to.texturePoint.x : to.texturePoint.y;
+ float delta = end - start;
+ if (Math.abs(delta) < ScreenGeometry.EPSILON) return to.copy();
+ float t = (limit - start) / delta;
+ return new ProjectedVertex(
+ new Vector2f(
+ from.point.x + (to.point.x - from.point.x) * t,
+ from.point.y + (to.point.y - from.point.y) * t
+ ),
+ new Vector2f(
+ from.texturePoint.x + (to.texturePoint.x - from.texturePoint.x) * t,
+ from.texturePoint.y + (to.texturePoint.y - from.texturePoint.y) * t
+ ),
+ new Vector3f(
+ from.vertex.x + (to.vertex.x - from.vertex.x) * t,
+ from.vertex.y + (to.vertex.y - from.vertex.y) * t,
+ from.vertex.z + (to.vertex.z - from.vertex.z) * t
+ )
+ );
+ }
+
+ private record ProjectedVertex(Vector2f point, Vector2f texturePoint, Vector3f vertex) {
+ private ProjectedVertex copy() {
+ return new ProjectedVertex(new Vector2f(point), new Vector2f(texturePoint), new Vector3f(vertex));
+ }
+ }
+
+}
diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/StartupGuideScreen.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/StartupGuideScreen.java
new file mode 100644
index 0000000..89814a3
--- /dev/null
+++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/StartupGuideScreen.java
@@ -0,0 +1,987 @@
+package com.github.squi2rel.vp.creation;
+
+import com.github.squi2rel.vp.NativeDownloadConfig;
+import com.github.squi2rel.vp.NativePackageManager;
+import com.github.squi2rel.vp.VideoPlayerClient;
+import com.github.squi2rel.vp.VideoPlayerMain;
+import com.github.squi2rel.vp.YtDlpManager;
+import com.github.squi2rel.vp.i18n.VpTexts;
+import com.github.squi2rel.vp.i18n.VpTranslation;
+import com.github.squi2rel.vp.i18n.VpTranslations;
+import com.github.squi2rel.vp.video.AudioChannelMode;
+import com.github.squi2rel.vp.video.MpvVideoBackend;
+import com.github.squi2rel.vp.video.VideoBackends;
+import com.github.squi2rel.vp.video.VlcDecoder;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.GuiGraphicsExtractor;
+import net.minecraft.client.gui.components.Renderable;
+import net.minecraft.client.gui.screens.Screen;
+import net.minecraft.network.chat.Component;
+
+public class StartupGuideScreen extends Screen {
+ private enum BackendRefreshResult {
+ AVAILABLE,
+ RETRY_SUCCEEDED,
+ RETRY_FAILED,
+ RESTART_REQUIRED
+ }
+
+ private record InstallationState(String vlcPlatform, boolean vlcInstalled, String mpvPlatform, boolean mpvInstalled) {
+ }
+
+ private record AvailabilityState(boolean vlcAvailable, boolean mpvAvailable) {
+ }
+
+ private static final VpUiTheme THEME = VpUiTheme.classic();
+ private static final int PANEL_WIDTH = 460;
+ private static final int PANEL_HEIGHT = 286;
+ private static final int MIN_PANEL_HEIGHT = 168;
+ private static final int CONTROL_HEIGHT = 18;
+ private static final int ROW_HEIGHT = 52;
+ private static final int YTDLP_START_Y = 62;
+ private static final int BACKEND_START_Y = YTDLP_START_Y + ROW_HEIGHT;
+ private static final int BACKEND_BUTTON_COUNT = 4;
+ private static final int GAP = 8;
+ private static final int SCROLL_STEP = 24;
+ private static final Map> NATIVE_DOWNLOAD_TASKS = new ConcurrentHashMap<>();
+
+ private final Screen parent;
+ private final Map browserIndex = new HashMap<>();
+
+ private VpButtonWidget vlcDownload;
+ private VpButtonWidget vlcCopyLink;
+ private VpButtonWidget vlcPlatform;
+ private VpButtonWidget vlcSelect;
+ private VpButtonWidget mpvDownload;
+ private VpButtonWidget mpvCopyLink;
+ private VpButtonWidget mpvPlatform;
+ private VpButtonWidget mpvSelect;
+ private VpButtonWidget ytdlpDownload;
+ private VpButtonWidget ytdlpCopyLink;
+ private VpButtonWidget ytdlpPlatform;
+ private VpButtonWidget audioChannelMode;
+ private VpButtonWidget done;
+ private VpButtonWidget skip;
+ private VpTextFieldWidget proxyField;
+ private VpTextFieldWidget ytdlPathField;
+
+ private CompletableFuture downloadTask;
+ private CompletableFuture ytdlpDetectionTask;
+ private CompletableFuture installationStateTask;
+ private CompletableFuture availabilityTask;
+ private String activeBackend = "";
+ private VpTranslation status = VpTranslation.EMPTY;
+ private int sourceIndex;
+ private int sourceCount;
+ private String sourceName = "";
+ private long bytesRead;
+ private long totalBytes;
+ private boolean vlcAvailable;
+ private boolean mpvAvailable;
+ private boolean vlcInstalled;
+ private boolean mpvInstalled;
+ private boolean ytdlpAvailable;
+ private String ytdlpVersion = "";
+ private String selectedYtdlpPlatform = NativeDownloadConfig.platformKey();
+ private String selectedVlcPlatform = NativeDownloadConfig.platformKey();
+ private String selectedMpvPlatform = NativeDownloadConfig.platformKey();
+ private int contentScroll;
+ private int panelLeft;
+ private int panelTop;
+ private int panelWidth;
+ private int panelHeight;
+ private int contentLeft;
+ private int contentTop;
+ private int contentRight;
+ private int contentBottom;
+ private int contentHeight;
+
+ public StartupGuideScreen(Screen parent) {
+ super(VpTexts.tr("screen.videoplayer.startup_guide", "VideoPlayer Guide"));
+ this.parent = parent;
+ }
+
+ @Override
+ protected void init() {
+ syncSelectedPlatformsWithConfig();
+ computeLayout();
+ int buttonW = buttonWidth();
+ int buttonX = buttonGroupX();
+
+ InputRowLayout inputRow = inputRowLayout();
+ proxyField = new VpTextFieldWidget(font, inputRow.proxyFieldX(), contentTop + 4, inputRow.proxyFieldWidth(), CONTROL_HEIGHT,
+ VpTexts.tr("label.videoplayer.proxy", "Proxy"), THEME);
+ proxyField.setMaxLength(220);
+ proxyField.setValue(currentProxy());
+ ytdlPathField = new VpTextFieldWidget(font, inputRow.ytdlFieldX(), contentTop + 4, inputRow.ytdlFieldWidth(), CONTROL_HEIGHT,
+ VpTexts.tr("label.videoplayer.ytdl_path", "yt-dlp"), THEME);
+ ytdlPathField.setMaxLength(4096);
+ ytdlPathField.setValue(currentYtdlPath());
+
+ audioChannelMode = button("", contentRight - audioChannelModeButtonWidth(), contentTop + 37,
+ audioChannelModeButtonWidth(), this::cycleAudioChannelMode);
+
+ ytdlpPlatform = button("", buttonX, contentTop + YTDLP_START_Y, buttonW, () -> {});
+ ytdlpDownload = button(VpTexts.tr("button.videoplayer.download", "Download"), buttonX + (buttonW + GAP) * 2,
+ contentTop + YTDLP_START_Y, buttonW, this::startYtdlpDownload);
+ ytdlpCopyLink = button(VpTexts.tr("button.videoplayer.copy_link", "Copy Link"), buttonX + (buttonW + GAP) * 3,
+ contentTop + YTDLP_START_Y, buttonW, this::copyYtdlpSourceLink);
+ mpvPlatform = button("", buttonX, contentTop + BACKEND_START_Y, buttonW, () -> cyclePlatform(VideoBackends.MPV));
+ mpvSelect = button(VpTexts.tr("button.videoplayer.select", "Select"), buttonX + buttonW + GAP, contentTop + BACKEND_START_Y, buttonW, () -> selectBackend(VideoBackends.MPV));
+ mpvDownload = button(VpTexts.tr("button.videoplayer.download", "Download"), buttonX + (buttonW + GAP) * 2, contentTop + BACKEND_START_Y, buttonW, () -> startDownload(VideoBackends.MPV));
+ mpvCopyLink = button(VpTexts.tr("button.videoplayer.copy_link", "Copy Link"), buttonX + (buttonW + GAP) * 3, contentTop + BACKEND_START_Y, buttonW, () -> copySourceLink(VideoBackends.MPV));
+ vlcPlatform = button("", buttonX, contentTop + vlcStartY(), buttonW, () -> cyclePlatform(VideoBackends.VLC));
+ vlcSelect = button(VpTexts.tr("button.videoplayer.select", "Select"), buttonX + buttonW + GAP, contentTop + vlcStartY(), buttonW, () -> selectBackend(VideoBackends.VLC));
+ vlcDownload = button(VpTexts.tr("button.videoplayer.download", "Download"), buttonX + (buttonW + GAP) * 2, contentTop + vlcStartY(), buttonW, () -> startDownload(VideoBackends.VLC));
+ vlcCopyLink = button(VpTexts.tr("button.videoplayer.copy_link", "Copy Link"), buttonX + (buttonW + GAP) * 3, contentTop + vlcStartY(), buttonW, () -> copySourceLink(VideoBackends.VLC));
+
+ int footerY = panelTop + panelHeight - 28;
+ skip = button(VpTexts.tr("button.videoplayer.skip", "Skip"), panelLeft + 24, footerY, 92, this::finish);
+ done = button(VpTexts.tr("button.videoplayer.done", "Done"), panelLeft + panelWidth - 116, footerY, 92, this::finish);
+
+ addRenderableWidget(proxyField);
+ addRenderableWidget(ytdlPathField);
+ addRenderableWidget(audioChannelMode);
+ addRenderableWidget(ytdlpPlatform);
+ addRenderableWidget(ytdlpDownload);
+ addRenderableWidget(ytdlpCopyLink);
+ addRenderableWidget(mpvPlatform);
+ addRenderableWidget(mpvSelect);
+ addRenderableWidget(mpvDownload);
+ addRenderableWidget(mpvCopyLink);
+ addRenderableWidget(vlcPlatform);
+ addRenderableWidget(vlcSelect);
+ addRenderableWidget(vlcDownload);
+ addRenderableWidget(vlcCopyLink);
+ addRenderableWidget(skip);
+ addRenderableWidget(done);
+
+ setMpvVisible(!VideoPlayerMain.android);
+ layoutWidgets();
+ refreshAvailability();
+ refreshInstallationState();
+ refreshYtdlpAvailability();
+ syncButtons();
+ }
+
+ @Override
+ public void tick() {
+ syncButtons();
+ }
+
+ @Override
+ public void onClose() {
+ finish();
+ }
+
+ @Override
+ public boolean isPauseScreen() {
+ return false;
+ }
+
+ @Override
+ public boolean mouseScrolled(double mouseX, double mouseY, double horizontalAmount, double verticalAmount) {
+ computeLayout();
+ if (!inside(mouseX, mouseY, contentLeft - 6, contentTop - 4, contentRight + 6, contentBottom + 4)) {
+ return super.mouseScrolled(mouseX, mouseY, horizontalAmount, verticalAmount);
+ }
+ int next = Math.clamp(contentScroll + (int) Math.round(-verticalAmount * SCROLL_STEP), 0, maxContentScroll());
+ if (next == contentScroll) {
+ return false;
+ }
+ contentScroll = next;
+ layoutWidgets();
+ return true;
+ }
+
+ @Override
+ public void extractRenderState(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
+ computeLayout();
+ layoutWidgets();
+ context.fill(0, 0, width, height, 0xB0000000);
+
+ context.fill(panelLeft, panelTop, panelLeft + panelWidth, panelTop + panelHeight, THEME.panelBackgroundColor());
+ context.outline(panelLeft, panelTop, panelWidth, panelHeight, THEME.panelBorderColor());
+ drawCenteredText(context, title, width / 2, panelTop + 12, THEME.primaryTextColor());
+
+ VpUiRenderer.drawBox(context, contentLeft - 6, contentTop - 4, contentRight - contentLeft + 12, contentBottom - contentTop + 8,
+ VpUiRenderer.darken(THEME.nodeBodyColor(), 0.06f), THEME.panelBorderColor());
+ context.enableScissor(contentLeft, contentTop, contentRight, contentBottom);
+ drawScrollableContent(context, mouseX, mouseY, delta);
+ context.disableScissor();
+ drawScrollbar(context);
+
+ Component line = statusLine();
+ if (!line.getString().isBlank()) {
+ drawText(context, trimToWidth(line, Math.max(40, panelWidth - 48)), panelLeft + 24, panelTop + panelHeight - 42, THEME.secondaryTextColor());
+ }
+
+ renderWidget(skip, context, mouseX, mouseY, delta);
+ renderWidget(done, context, mouseX, mouseY, delta);
+ }
+
+ private VpButtonWidget button(String label, int x, int y, int width, Runnable action) {
+ return new VpButtonWidget(x, y, width, CONTROL_HEIGHT, Component.literal(label), ignored -> action.run(), THEME);
+ }
+
+ private VpButtonWidget button(Component label, int x, int y, int width, Runnable action) {
+ return new VpButtonWidget(x, y, width, CONTROL_HEIGHT, label, ignored -> action.run(), THEME);
+ }
+
+ private void computeLayout() {
+ panelWidth = Math.min(PANEL_WIDTH, Math.max(220, width - 32));
+ panelHeight = Math.min(PANEL_HEIGHT, Math.max(120, height - 24));
+ if (height >= MIN_PANEL_HEIGHT + 24) {
+ panelHeight = Math.max(MIN_PANEL_HEIGHT, panelHeight);
+ }
+ panelLeft = (width - panelWidth) / 2;
+ panelTop = Math.max(8, (height - panelHeight) / 2);
+ contentLeft = panelLeft + 24;
+ contentRight = panelLeft + panelWidth - 24;
+ contentTop = panelTop + 34;
+ contentBottom = Math.max(contentTop + CONTROL_HEIGHT, panelTop + panelHeight - 50);
+ contentHeight = vlcStartY() + ROW_HEIGHT + 12;
+ contentScroll = Math.clamp(contentScroll, 0, maxContentScroll());
+ }
+
+ private int vlcStartY() {
+ return BACKEND_START_Y + (VideoPlayerMain.android ? 0 : ROW_HEIGHT);
+ }
+
+ private void setMpvVisible(boolean visible) {
+ mpvPlatform.visible = visible;
+ mpvSelect.visible = visible;
+ mpvDownload.visible = visible;
+ mpvCopyLink.visible = visible;
+ }
+
+ private void layoutWidgets() {
+ if (proxyField == null || ytdlPathField == null || audioChannelMode == null || ytdlpPlatform == null || mpvPlatform == null || skip == null || done == null) {
+ return;
+ }
+
+ int y = contentY();
+ int buttonX = buttonGroupX();
+ int buttonW = buttonWidth();
+ InputRowLayout inputRow = inputRowLayout();
+ proxyField.setX(inputRow.proxyFieldX());
+ proxyField.setY(y + 4);
+ proxyField.clip(contentLeft, contentTop, contentRight, contentBottom);
+ ytdlPathField.setX(inputRow.ytdlFieldX());
+ ytdlPathField.setY(y + 4);
+ ytdlPathField.clip(contentLeft, contentTop, contentRight, contentBottom);
+
+ audioChannelMode.setX(contentRight - audioChannelModeButtonWidth());
+ audioChannelMode.setY(y + 37);
+ audioChannelMode.clip(contentLeft, contentTop, contentRight, contentBottom);
+
+ ytdlpPlatform.setX(buttonX);
+ ytdlpPlatform.setY(y + YTDLP_START_Y);
+ ytdlpPlatform.clip(contentLeft, contentTop, contentRight, contentBottom);
+ ytdlpDownload.setX(buttonX + (buttonW + GAP) * 2);
+ ytdlpDownload.setY(y + YTDLP_START_Y);
+ ytdlpDownload.clip(contentLeft, contentTop, contentRight, contentBottom);
+ ytdlpCopyLink.setX(buttonX + (buttonW + GAP) * 3);
+ ytdlpCopyLink.setY(y + YTDLP_START_Y);
+ ytdlpCopyLink.clip(contentLeft, contentTop, contentRight, contentBottom);
+
+ layoutBackendButtons(mpvPlatform, mpvSelect, mpvDownload, mpvCopyLink, buttonX, y + BACKEND_START_Y, buttonW);
+ layoutBackendButtons(vlcPlatform, vlcSelect, vlcDownload, vlcCopyLink, buttonX, y + vlcStartY(), buttonW);
+
+ int footerY = panelTop + panelHeight - 28;
+ skip.setX(panelLeft + 24);
+ skip.setY(footerY);
+ done.setX(panelLeft + panelWidth - 116);
+ done.setY(footerY);
+ }
+
+ private void layoutBackendButtons(VpButtonWidget platform, VpButtonWidget select, VpButtonWidget download, VpButtonWidget copyLink,
+ int x, int y, int width) {
+ platform.setX(x);
+ platform.setY(y);
+ platform.clip(contentLeft, contentTop, contentRight, contentBottom);
+ select.setX(x + width + GAP);
+ select.setY(y);
+ select.clip(contentLeft, contentTop, contentRight, contentBottom);
+ download.setX(x + (width + GAP) * 2);
+ download.setY(y);
+ download.clip(contentLeft, contentTop, contentRight, contentBottom);
+ copyLink.setX(x + (width + GAP) * 3);
+ copyLink.setY(y);
+ copyLink.clip(contentLeft, contentTop, contentRight, contentBottom);
+ }
+
+ private void drawScrollableContent(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
+ int y = contentY();
+ InputRowLayout inputRow = inputRowLayout();
+ drawText(context, VpTexts.tr("label.videoplayer.proxy_colon", "Proxy:"), contentLeft, y + 5, THEME.secondaryTextColor());
+ renderWidget(proxyField, context, mouseX, mouseY, delta);
+ drawText(context, VpTexts.tr("label.videoplayer.ytdl_path_colon", "YTDL:"), inputRow.ytdlLabelX(), y + 5, THEME.secondaryTextColor());
+ renderWidget(ytdlPathField, context, mouseX, mouseY, delta);
+
+ int infoY = y + 28;
+ int infoW = Math.max(40, (contentRight - contentLeft - GAP) / 2);
+ drawText(context, trimToWidth(VpTexts.tr("label.videoplayer.system", "System: %s", NativeDownloadConfig.osKey()), infoW), contentLeft, infoY, THEME.secondaryTextColor());
+ drawText(context, trimToWidth(VpTexts.tr("label.videoplayer.recommended", "Recommended: %s", platformLabel(NativePackageManager.platformKey())), infoW), contentLeft + infoW + GAP, infoY, THEME.secondaryTextColor());
+ int audioButtonWidth = audioChannelModeButtonWidth();
+ int backendInfoWidth = Math.max(40, contentRight - contentLeft - audioButtonWidth - GAP);
+ drawText(context, trimToWidth(VpTexts.tr("label.videoplayer.current_backend", "Current backend: %s", VideoBackends.normalize(VideoPlayerClient.config.videoBackend)), backendInfoWidth), contentLeft, infoY + 14, THEME.secondaryTextColor());
+ renderWidget(audioChannelMode, context, mouseX, mouseY, delta);
+
+ drawYtdlp(context, contentLeft, y + YTDLP_START_Y);
+ renderWidget(ytdlpPlatform, context, mouseX, mouseY, delta);
+ renderWidget(ytdlpDownload, context, mouseX, mouseY, delta);
+ renderWidget(ytdlpCopyLink, context, mouseX, mouseY, delta);
+
+ if (!VideoPlayerMain.android) {
+ drawBackend(context, contentLeft, y + BACKEND_START_Y, VideoBackends.MPV, VpTexts.tr("label.videoplayer.mpv_recommended", "MPV Recommended"), mpvAvailable);
+ renderWidget(mpvPlatform, context, mouseX, mouseY, delta);
+ renderWidget(mpvSelect, context, mouseX, mouseY, delta);
+ renderWidget(mpvDownload, context, mouseX, mouseY, delta);
+ renderWidget(mpvCopyLink, context, mouseX, mouseY, delta);
+ }
+
+ drawBackend(context, contentLeft, y + vlcStartY(), VideoBackends.VLC, Component.literal("VLC"), vlcAvailable);
+ renderWidget(vlcPlatform, context, mouseX, mouseY, delta);
+ renderWidget(vlcSelect, context, mouseX, mouseY, delta);
+ renderWidget(vlcDownload, context, mouseX, mouseY, delta);
+ renderWidget(vlcCopyLink, context, mouseX, mouseY, delta);
+ }
+
+ private void drawBackend(GuiGraphicsExtractor context, int x, int y, String backend, Component label, boolean available) {
+ int color = available ? THEME.executionColor() : THEME.errorColor();
+ int count = sourceCount(backend);
+ String platform = selectedPlatform(backend);
+ Component installed = backendInstalled(backend)
+ ? VpTexts.tr("label.videoplayer.installed", "Installed")
+ : VpTexts.tr("label.videoplayer.not_installed", "Not installed");
+ Component sources = count <= 0
+ ? VpTexts.tr("label.videoplayer.no_sources", "No sources configured")
+ : VpTexts.tr("label.videoplayer.source_count", "%s sources", count);
+ int textW = Math.max(40, buttonGroupX() - x - GAP);
+ Component visibleLabel = trimToWidth(label, Math.max(32, textW - 54));
+ int statusX = x + Math.max(52, font.width(visibleLabel) + 8);
+ Component availability = available
+ ? VpTexts.tr("label.videoplayer.available", "Available")
+ : VpTexts.tr("label.videoplayer.unavailable", "Unavailable");
+ int platformX = statusX + font.width(availability) + 8;
+ drawText(context, visibleLabel, x, y, THEME.primaryTextColor());
+ drawText(context, availability, statusX, y, color);
+ drawText(context, trimToWidth(platformText(platform), Math.max(24, textW - (platformX - x))), platformX, y, THEME.secondaryTextColor());
+ drawText(context, trimToWidth(VpTexts.tr("label.videoplayer.install_source_status", "%s / %s", installed.getString(), sources.getString()), textW), x, y + 16, THEME.secondaryTextColor());
+ }
+
+ private boolean backendInstalled(String backend) {
+ return VideoBackends.MPV.equals(VideoBackends.normalize(backend)) ? mpvInstalled : vlcInstalled;
+ }
+
+ private void drawYtdlp(GuiGraphicsExtractor context, int x, int y) {
+ int color = ytdlpAvailable ? THEME.executionColor() : THEME.errorColor();
+ int count = ytdlpSources().size();
+ int textW = Math.max(40, buttonGroupX() - x - GAP);
+ Component availability = ytdlpDetectionTask != null
+ ? VpTexts.tr("label.videoplayer.checking", "Checking")
+ : ytdlpAvailable
+ ? VpTexts.tr("label.videoplayer.available", "Available")
+ : VpTexts.tr("label.videoplayer.unavailable", "Unavailable");
+ drawText(context, Component.literal("yt-dlp"), x, y, THEME.primaryTextColor());
+ drawText(context, availability, x + 48, y, color);
+ Component detail = ytdlpVersion.isBlank()
+ ? VpTexts.tr("label.videoplayer.source_count", "%s sources", count)
+ : VpTexts.tr("label.videoplayer.ytdlp_version", "Version %s", ytdlpVersion);
+ drawText(context, trimToWidth(detail, textW), x, y + 16, THEME.secondaryTextColor());
+ }
+
+ private void drawScrollbar(GuiGraphicsExtractor context) {
+ int viewportHeight = contentBottom - contentTop;
+ int maxScroll = maxContentScroll();
+ if (maxScroll <= 0 || viewportHeight <= 0) {
+ return;
+ }
+ int x = contentRight + 2;
+ int thumbHeight = Math.max(14, viewportHeight * viewportHeight / Math.max(viewportHeight, contentHeight));
+ int thumbTravel = Math.max(1, viewportHeight - thumbHeight);
+ int thumbY = contentTop + thumbTravel * Math.clamp(contentScroll, 0, maxScroll) / maxScroll;
+ int trackColor = VpUiRenderer.withAlpha(VpUiRenderer.blend(THEME.panelBorderColor(), THEME.panelBackgroundColor(), 0.55f), 0x88);
+ int thumbColor = VpUiRenderer.withAlpha(VpUiRenderer.blend(THEME.secondaryTextColor(), THEME.accentColor(), 0.35f), 0xDD);
+ VpUiRenderer.drawBox(context, x, contentTop, 4, viewportHeight, trackColor, trackColor);
+ VpUiRenderer.drawBox(context, x, thumbY, 4, thumbHeight, thumbColor, thumbColor);
+ }
+
+ private int contentY() {
+ return contentTop - contentScroll;
+ }
+
+ private int maxContentScroll() {
+ return Math.max(0, contentHeight - Math.max(1, contentBottom - contentTop));
+ }
+
+ private int buttonGroupX() {
+ return contentRight - 8 - buttonWidth() * BACKEND_BUTTON_COUNT - GAP * (BACKEND_BUTTON_COUNT - 1);
+ }
+
+ private int buttonWidth() {
+ int groupW = Math.min(contentRight - contentLeft - 16, 256);
+ return Math.max(34, (groupW - GAP * (BACKEND_BUTTON_COUNT - 1)) / BACKEND_BUTTON_COUNT);
+ }
+
+ private int audioChannelModeButtonWidth() {
+ return Math.min(132, Math.max(88, (contentRight - contentLeft) / 3));
+ }
+
+ private void renderWidget(Renderable widget, GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
+ if (widget != null) {
+ widget.extractRenderState(context, mouseX, mouseY, delta);
+ }
+ }
+
+ private void drawText(GuiGraphicsExtractor context, Component text, int x, int y, int color) {
+ if (THEME.textShadow()) {
+ context.text(font, text, x, y, color);
+ return;
+ }
+ context.text(font, text, x, y, color, false);
+ }
+
+ private void drawCenteredText(GuiGraphicsExtractor context, Component text, int centerX, int y, int color) {
+ drawText(context, text, centerX - font.width(text) / 2, y, color);
+ }
+
+ private Component trimToWidth(Component text, int maxWidth) {
+ return Component.literal(trimToWidth(text.getString(), maxWidth));
+ }
+
+ private String trimToWidth(String text, int maxWidth) {
+ String value = text == null ? "" : text;
+ if (font.width(value) <= maxWidth) return value;
+ String suffix = "...";
+ return font.plainSubstrByWidth(value, Math.max(0, maxWidth - font.width(suffix))) + suffix;
+ }
+
+ private Component statusLine() {
+ if (downloadTask == null) return VpTexts.text(status);
+ String backend = activeBackend.equals(YtDlpManager.TOOL_NAME)
+ ? "yt-dlp"
+ : activeBackend.equals(VideoBackends.MPV) ? "MPV" : "VLC";
+ String namedSource = sourceName.isBlank() ? "" : " " + sourceName;
+ String source = sourceCount <= 0 ? "" : " " + sourceIndex + "/" + sourceCount;
+ String progress = "";
+ if (totalBytes > 0) {
+ long percent = Math.clamp(Math.round(bytesRead * 100.0 / totalBytes), 0, 100);
+ progress = " " + percent + "%";
+ }
+ return VpTexts.tr("message.videoplayer.native.status_line", "%s%s%s%s %s", backend, namedSource, source, progress, VpTexts.text(status).getString());
+ }
+
+ private void syncButtons() {
+ boolean idle = downloadTask == null;
+ setMpvVisible(!VideoPlayerMain.android);
+ if (VideoPlayerMain.android) {
+ mpvPlatform.active = false;
+ mpvSelect.active = false;
+ mpvDownload.active = false;
+ mpvCopyLink.active = false;
+ } else {
+ syncBackendButtons(VideoBackends.MPV, mpvPlatform, mpvSelect, mpvDownload, mpvCopyLink, idle);
+ }
+ syncBackendButtons(VideoBackends.VLC, vlcPlatform, vlcSelect, vlcDownload, vlcCopyLink, idle);
+ AudioChannelMode configuredAudioChannelMode = AudioChannelMode.normalize(VideoPlayerClient.config.audioChannelMode);
+ boolean audioRestartRequired = configuredAudioChannelMode != VideoPlayerClient.activeAudioChannelMode();
+ audioChannelMode.active = idle;
+ audioChannelMode.selected(audioRestartRequired);
+ audioChannelMode.setMessage(VpTexts.tr(
+ audioRestartRequired
+ ? "button.videoplayer.audio_channel_mode_restart_required"
+ : "button.videoplayer.audio_channel_mode",
+ audioRestartRequired ? "Restart: %s" : "Audio: %s",
+ audioChannelModeLabel(configuredAudioChannelMode).getString()
+ ));
+ int ytdlpCount = ytdlpSources().size();
+ ytdlpPlatform.active = false;
+ ytdlpPlatform.selected(false);
+ ytdlpPlatform.setMessage(platformText(selectedYtdlpPlatform));
+ ytdlpDownload.active = idle && ytdlpCount > 0;
+ ytdlpCopyLink.active = idle && ytdlpCount > 0;
+ ytdlpDownload.setMessage(ytdlpCount > 0
+ ? VpTexts.tr("button.videoplayer.download", "Download")
+ : VpTexts.tr("button.videoplayer.not_configured", "Not configured"));
+ ytdlpCopyLink.setMessage(ytdlpCount > 1
+ ? VpTexts.tr("button.videoplayer.copy_link_index", "Copy %s/%s", browserIndex.getOrDefault(YtDlpManager.TOOL_NAME, 0) + 1, ytdlpCount)
+ : VpTexts.tr("button.videoplayer.copy_link", "Copy Link"));
+ if (proxyField != null) proxyField.active = idle;
+ if (ytdlPathField != null) ytdlPathField.active = idle;
+ skip.active = idle;
+ done.active = idle;
+ }
+
+ private void syncBackendButtons(String backend, VpButtonWidget platform, VpButtonWidget select, VpButtonWidget download, VpButtonWidget copyLink, boolean idle) {
+ int count = sourceCount(backend);
+ List platforms = platformOptions(backend);
+ String selectedPlatform = selectedPlatform(backend);
+ boolean selectedBackend = VideoBackends.normalize(backend).equals(VideoBackends.normalize(VideoPlayerClient.config.videoBackend));
+ platform.active = idle && platforms.size() > 1;
+ platform.selected(!selectedPlatform.equals(NativePackageManager.platformKey()));
+ platform.setMessage(platformText(selectedPlatform));
+ select.active = idle && !selectedBackend;
+ select.selected(selectedBackend);
+ select.setMessage(VpTexts.tr("button.videoplayer.select", "Select"));
+ download.active = idle && count > 0;
+ copyLink.active = idle && count > 0;
+ download.setMessage(count > 0 ? VpTexts.tr("button.videoplayer.download", "Download") : VpTexts.tr("button.videoplayer.not_configured", "Not configured"));
+ copyLink.setMessage(count > 1
+ ? VpTexts.tr("button.videoplayer.copy_link_index", "Copy %s/%s", browserIndex.getOrDefault(backend, 0) + 1, count)
+ : VpTexts.tr("button.videoplayer.copy_link", "Copy Link"));
+ }
+
+ private void startDownload(String backend) {
+ if (downloadTask != null) return;
+ if (VideoPlayerMain.android && VideoBackends.MPV.equals(backend)) return;
+ String platform = selectedPlatform(backend);
+ List sources = sources(backend);
+ if (sources.isEmpty()) return;
+ String proxy = persistProxy();
+ persistYtdlPath();
+ activeBackend = backend;
+ status = VpTranslation.of("message.videoplayer.native.prepare_download", "Preparing download");
+ sourceIndex = 0;
+ sourceCount = sources.size();
+ sourceName = "";
+ bytesRead = 0;
+ totalBytes = -1;
+
+ String taskKey = nativeTaskKey(backend, platform);
+ CompletableFuture sharedTask = NATIVE_DOWNLOAD_TASKS.computeIfAbsent(taskKey,
+ ignored -> CompletableFuture.supplyAsync(() -> NativePackageManager.downloadAndInstall(backend, platform, sources, proxy, progress ->
+ Minecraft.getInstance().execute(() -> {
+ sourceIndex = progress.sourceIndex();
+ sourceCount = progress.sourceCount();
+ sourceName = progress.sourceName();
+ bytesRead = progress.bytesRead();
+ totalBytes = progress.totalBytes();
+ status = progress.message();
+ }))));
+ downloadTask = sharedTask;
+ sharedTask.whenComplete((result, error) -> {
+ NATIVE_DOWNLOAD_TASKS.remove(taskKey, sharedTask);
+ Minecraft.getInstance().execute(() -> {
+ downloadTask = null;
+ if (error != null) {
+ status = VpTranslations.from(error, "error.videoplayer.native.download_failed", "Download failed: %s", error.getMessage() == null ? "" : error.getMessage());
+ return;
+ }
+ status = result.message();
+ if (result.success()) {
+ markBackendInstalled(backend, true);
+ BackendRefreshResult refreshResult = refreshBackendAfterRuntimeChange(backend);
+ if (refreshResult == BackendRefreshResult.RESTART_REQUIRED) {
+ status = VpTranslation.of("message.videoplayer.native.restart_required",
+ "%s 运行库已安装。重启 Minecraft 后使用新运行库。", backendName(backend));
+ } else if (refreshResult == BackendRefreshResult.RETRY_FAILED) {
+ status = VpTranslation.of("error.videoplayer.native.load_failed_after_install",
+ "%s 运行库已安装但加载失败。请重启 Minecraft 或检查游戏日志。", backendName(backend));
+ }
+ if (!backendAvailable(VideoPlayerClient.config.videoBackend) && backendAvailable(backend)) {
+ VideoPlayerClient.config.videoBackend = VideoBackends.normalize(backend);
+ VideoPlayerClient.saveConfig();
+ }
+ }
+ syncButtons();
+ });
+ });
+ }
+
+ private void startYtdlpDownload() {
+ if (downloadTask != null) return;
+ List sources = ytdlpSources();
+ if (sources.isEmpty()) return;
+ String proxy = persistProxy();
+ persistYtdlPath();
+ activeBackend = YtDlpManager.TOOL_NAME;
+ status = VpTranslation.of("message.videoplayer.native.prepare_download", "Preparing download");
+ sourceIndex = 0;
+ sourceCount = sources.size();
+ sourceName = "";
+ bytesRead = 0;
+ totalBytes = -1;
+ NativeDownloadConfig config = nativeDownloads();
+ downloadTask = CompletableFuture.supplyAsync(() -> YtDlpManager.downloadAndInstall(config, selectedYtdlpPlatform, proxy, progress ->
+ Minecraft.getInstance().execute(() -> {
+ sourceIndex = progress.sourceIndex();
+ sourceCount = progress.sourceCount();
+ sourceName = progress.sourceName();
+ bytesRead = progress.bytesRead();
+ totalBytes = progress.totalBytes();
+ status = progress.message();
+ })));
+ downloadTask.whenComplete((result, error) -> Minecraft.getInstance().execute(() -> {
+ downloadTask = null;
+ if (error != null) {
+ status = VpTranslations.from(error, "error.videoplayer.native.download_failed", "Download failed: %s",
+ error.getMessage() == null ? "" : error.getMessage());
+ return;
+ }
+ status = result.message();
+ if (result.success()) {
+ ytdlPathField.setValue("");
+ VideoPlayerClient.config.mpvYtdlPath = "";
+ VideoPlayerClient.saveConfig();
+ VideoPlayerClient.applyNativePlatformConfig();
+ refreshYtdlpAvailability();
+ }
+ }));
+ }
+
+ private void copySourceLink(String backend) {
+ List sources = sources(backend);
+ if (sources.isEmpty()) return;
+ int index = Math.floorMod(browserIndex.getOrDefault(backend, 0), sources.size());
+ String url = sources.get(index).url;
+ browserIndex.put(backend, (index + 1) % sources.size());
+ copyLink(url);
+ }
+
+ private void copyYtdlpSourceLink() {
+ List sources = ytdlpSources();
+ if (sources.isEmpty()) return;
+ int index = Math.floorMod(browserIndex.getOrDefault(YtDlpManager.TOOL_NAME, 0), sources.size());
+ browserIndex.put(YtDlpManager.TOOL_NAME, (index + 1) % sources.size());
+ copyLink(sources.get(index).url);
+ }
+
+ private void copyLink(String url) {
+ try {
+ Minecraft.getInstance().keyboardHandler.setClipboard(url);
+ status = VpTranslation.of("message.videoplayer.native.link_copied", "Download link copied");
+ } catch (RuntimeException e) {
+ status = VpTranslation.of("error.videoplayer.copy_link_failed", "Unable to copy link: %s", e.getMessage());
+ }
+ }
+
+ private void selectBackend(String backend) {
+ if (VideoPlayerClient.config == null) return;
+ String normalized = VideoBackends.normalize(backend);
+ VideoPlayerClient.config.videoBackend = normalized;
+ VideoPlayerClient.saveConfig();
+ refreshAvailability();
+ if (VideoBackends.MPV.equals(normalized) && !mpvAvailable) {
+ status = VpTranslation.of(
+ "message.videoplayer.backend_mpv_unavailable",
+ "MPV is unavailable. Download the MPV runtime below; new videos use VLC until installation finishes."
+ );
+ } else {
+ status = VpTranslation.of("message.videoplayer.backend_set", "Playback backend set to %s. Only newly started videos are affected.", normalized);
+ }
+ syncButtons();
+ }
+
+ private void cycleAudioChannelMode() {
+ if (VideoPlayerClient.config == null) return;
+ AudioChannelMode configured = AudioChannelMode.normalize(VideoPlayerClient.config.audioChannelMode);
+ AudioChannelMode next = configured == AudioChannelMode.STEREO ? AudioChannelMode.AUTO : AudioChannelMode.STEREO;
+ VideoPlayerClient.config.audioChannelMode = next.configValue();
+ VideoPlayerClient.saveConfig();
+ boolean restartRequired = next != VideoPlayerClient.activeAudioChannelMode();
+ status = VpTranslation.of(
+ restartRequired ? "message.videoplayer.audio_channel_mode_restart_required" : "message.videoplayer.audio_channel_mode_set",
+ restartRequired
+ ? "Audio channel mode saved as %s. Restart Minecraft to apply."
+ : "Audio channel mode saved as %s and is already active.",
+ audioChannelModeLabel(next).getString()
+ );
+ syncButtons();
+ }
+
+ private Component audioChannelModeLabel(AudioChannelMode mode) {
+ return VpTexts.tr(
+ "label.videoplayer.audio_channel_mode." + mode.configValue(),
+ mode == AudioChannelMode.AUTO ? "Auto" : "Stereo"
+ );
+ }
+
+ private int sourceCount(String backend) {
+ return sources(backend).size();
+ }
+
+ private List sources(String backend) {
+ return sources(backend, selectedPlatform(backend));
+ }
+
+ private List sources(String backend, String platform) {
+ if (VideoPlayerMain.android && VideoBackends.MPV.equals(backend)) return List.of();
+ return nativeDownloads().sources(backend, platform);
+ }
+
+ private String currentProxy() {
+ if (VideoPlayerClient.config == null || VideoPlayerClient.config.nativeDownloadProxy == null) return "";
+ return VideoPlayerClient.config.nativeDownloadProxy;
+ }
+
+ private String persistProxy() {
+ String proxy = proxyField == null ? currentProxy() : proxyField.getValue().trim();
+ if (VideoPlayerClient.config != null && !Objects.equals(VideoPlayerClient.config.nativeDownloadProxy, proxy)) {
+ VideoPlayerClient.config.nativeDownloadProxy = proxy;
+ VideoPlayerClient.saveConfig();
+ }
+ return proxy;
+ }
+
+ private String currentYtdlPath() {
+ if (VideoPlayerClient.config == null || VideoPlayerClient.config.mpvYtdlPath == null) return "";
+ String path = VideoPlayerClient.config.mpvYtdlPath.trim();
+ return YtDlpManager.isCurrentManagedExecutable(path) ? "" : path;
+ }
+
+ private void persistYtdlPath() {
+ String path = ytdlPathField == null ? currentYtdlPath() : ytdlPathField.getValue().trim();
+ if (YtDlpManager.isCurrentManagedExecutable(path)) path = "";
+ if (VideoPlayerClient.config != null && !Objects.equals(VideoPlayerClient.config.mpvYtdlPath, path)) {
+ VideoPlayerClient.config.mpvYtdlPath = path;
+ VideoPlayerClient.saveConfig();
+ }
+ }
+
+ private List platformOptions(String backend) {
+ if (VideoPlayerMain.android) {
+ return VideoBackends.MPV.equals(backend) ? List.of() : List.of(NativeDownloadConfig.ANDROID_ARM64);
+ }
+ String selected = selectedPlatform(backend);
+ String recommended = NativePackageManager.platformKey();
+ List result = new ArrayList<>();
+ for (String platform : nativeDownloads().platformsForCurrentOs()) {
+ if (platform.equals(selected) || platform.equals(recommended) || !sources(backend, platform).isEmpty()) {
+ result.add(platform);
+ }
+ }
+ if (!result.contains(recommended)) {
+ result.add(0, recommended);
+ }
+ if (!result.contains(selected)) {
+ result.add(0, selected);
+ }
+ return result;
+ }
+
+ private List ytdlpSources() {
+ return nativeDownloads().tool(YtDlpManager.TOOL_NAME).sources(selectedYtdlpPlatform);
+ }
+
+ private NativeDownloadConfig nativeDownloads() {
+ return VideoPlayerClient.nativeDownloadConfig();
+ }
+
+ private void cyclePlatform(String backend) {
+ List options = platformOptions(backend);
+ if (options.size() <= 1) return;
+ int index = options.indexOf(selectedPlatform(backend));
+ selectPlatform(backend, options.get(Math.floorMod(index + 1, options.size())));
+ }
+
+ private void selectPlatform(String backend, String platform) {
+ String normalized = NativeDownloadConfig.normalizePlatformForCurrentOs(platform);
+ String previous = selectedPlatform(backend);
+ if (VideoBackends.MPV.equals(VideoBackends.normalize(backend))) {
+ selectedMpvPlatform = normalized;
+ VideoPlayerClient.config.nativeMpvPlatform = normalized;
+ } else {
+ selectedVlcPlatform = normalized;
+ VideoPlayerClient.config.nativeVlcPlatform = normalized;
+ }
+ VideoPlayerClient.applyNativePlatformConfig();
+ VideoPlayerClient.saveConfig();
+ browserIndex.remove(backend);
+ refreshInstallationState();
+ BackendRefreshResult refreshResult = Objects.equals(previous, normalized)
+ ? BackendRefreshResult.AVAILABLE
+ : refreshBackendAfterRuntimeChange(backend);
+ if (refreshResult == BackendRefreshResult.RESTART_REQUIRED) {
+ status = VpTranslation.of("message.videoplayer.native.platform_restart_required",
+ "%s platform saved as %s. Restart Minecraft to use it.", backendName(backend), platformLabel(normalized));
+ } else if (refreshResult == BackendRefreshResult.RETRY_FAILED) {
+ status = VpTranslation.of("error.videoplayer.native.platform_load_failed",
+ "%s platform saved as %s, but the runtime is unavailable.", backendName(backend), platformLabel(normalized));
+ } else {
+ status = VpTranslation.of("message.videoplayer.native.platform_selected", "%s platform: %s",
+ backendName(backend), platformLabel(normalized));
+ }
+ }
+
+ private String selectedPlatform(String backend) {
+ return VideoBackends.MPV.equals(VideoBackends.normalize(backend)) ? selectedMpvPlatform : selectedVlcPlatform;
+ }
+
+ private void syncSelectedPlatformsWithConfig() {
+ if (VideoPlayerClient.config == null) return;
+ if (VideoPlayerMain.android) {
+ selectedVlcPlatform = NativeDownloadConfig.ANDROID_ARM64;
+ selectedMpvPlatform = NativeDownloadConfig.ANDROID_ARM64;
+ } else {
+ selectedVlcPlatform = NativeDownloadConfig.normalizePlatformForCurrentOs(VideoPlayerClient.config.nativeVlcPlatform);
+ selectedMpvPlatform = NativeDownloadConfig.normalizePlatformForCurrentOs(VideoPlayerClient.config.nativeMpvPlatform);
+ }
+ VideoPlayerClient.config.nativeVlcPlatform = selectedVlcPlatform;
+ VideoPlayerClient.config.nativeMpvPlatform = selectedMpvPlatform;
+ VideoPlayerClient.applyNativePlatformConfig();
+ }
+
+ private String platformLabel(String platform) {
+ return platformText(platform).getString();
+ }
+
+ private Component platformText(String platform) {
+ String arch = NativeDownloadConfig.archFromPlatform(platform);
+ if (arch.isBlank()) return Component.literal(platform == null ? "" : platform);
+ return platform.equals(NativePackageManager.platformKey())
+ ? VpTexts.tr("label.videoplayer.platform_recommended", "%s Recommended", arch)
+ : Component.literal(arch);
+ }
+
+ private void refreshAvailability() {
+ if (VideoPlayerMain.android) {
+ vlcAvailable = vlcInstalled;
+ mpvAvailable = false;
+ return;
+ }
+ if (availabilityTask != null) return;
+ availabilityTask = CompletableFuture.supplyAsync(() -> new AvailabilityState(
+ VlcDecoder.isAvailable(),
+ MpvVideoBackend.isAvailable()
+ ));
+ availabilityTask.whenComplete((state, error) -> Minecraft.getInstance().execute(() -> {
+ availabilityTask = null;
+ if (error != null || state == null) {
+ vlcAvailable = false;
+ mpvAvailable = false;
+ } else {
+ vlcAvailable = state.vlcAvailable();
+ mpvAvailable = state.mpvAvailable();
+ }
+ syncButtons();
+ }));
+ }
+
+ private void refreshInstallationState() {
+ if (installationStateTask != null) return;
+ String vlcPlatform = selectedVlcPlatform;
+ String mpvPlatform = selectedMpvPlatform;
+ installationStateTask = CompletableFuture.supplyAsync(() -> new InstallationState(
+ vlcPlatform,
+ NativePackageManager.isInstalled(VideoBackends.VLC, vlcPlatform),
+ mpvPlatform,
+ !VideoPlayerMain.android && NativePackageManager.isInstalled(VideoBackends.MPV, mpvPlatform)
+ ));
+ installationStateTask.whenComplete((state, error) -> Minecraft.getInstance().execute(() -> {
+ installationStateTask = null;
+ if (error != null || state == null) return;
+ if (!Objects.equals(state.vlcPlatform(), selectedVlcPlatform)
+ || !Objects.equals(state.mpvPlatform(), selectedMpvPlatform)) {
+ refreshInstallationState();
+ return;
+ }
+ vlcInstalled = state.vlcInstalled();
+ mpvInstalled = state.mpvInstalled();
+ if (VideoPlayerMain.android) {
+ vlcAvailable = vlcInstalled;
+ mpvAvailable = false;
+ }
+ syncButtons();
+ }));
+ }
+
+ private void refreshYtdlpAvailability() {
+ if (ytdlpDetectionTask != null) return;
+ String configured = ytdlPathField == null ? currentYtdlPath() : ytdlPathField.getValue().trim();
+ ytdlpDetectionTask = CompletableFuture.supplyAsync(() -> YtDlpManager.detect(configured));
+ ytdlpDetectionTask.whenComplete((detection, error) -> Minecraft.getInstance().execute(() -> {
+ ytdlpDetectionTask = null;
+ ytdlpAvailable = error == null && detection != null && detection.available();
+ ytdlpVersion = ytdlpAvailable ? detection.version() : "";
+ VideoPlayerClient.applyNativePlatformConfig();
+ }));
+ }
+
+ private boolean backendAvailable(String backend) {
+ return VideoBackends.MPV.equals(VideoBackends.normalize(backend)) ? mpvAvailable : vlcAvailable;
+ }
+
+ private void markBackendInstalled(String backend, boolean installed) {
+ if (VideoBackends.MPV.equals(backend)) {
+ mpvInstalled = installed;
+ } else {
+ vlcInstalled = installed;
+ }
+ }
+
+ private String nativeTaskKey(String backend, String platform) {
+ return NativeDownloadConfig.normalizeBackend(backend) + ":" + platform;
+ }
+
+ private BackendRefreshResult refreshBackendAfterRuntimeChange(String backend) {
+ if (VideoPlayerMain.android) {
+ mpvAvailable = false;
+ if (VideoBackends.MPV.equals(backend)) return BackendRefreshResult.RETRY_FAILED;
+ VlcDecoder.resetLoadState();
+ vlcAvailable = vlcInstalled && VlcDecoder.isAvailable();
+ return vlcAvailable ? BackendRefreshResult.RETRY_SUCCEEDED : BackendRefreshResult.RETRY_FAILED;
+ }
+ if (backendLoaded(backend)) return BackendRefreshResult.RESTART_REQUIRED;
+ boolean available;
+ if (VideoBackends.MPV.equals(VideoBackends.normalize(backend))) {
+ MpvVideoBackend.resetAvailability();
+ available = MpvVideoBackend.isAvailable();
+ mpvAvailable = available;
+ } else {
+ VlcDecoder.resetLoadState();
+ available = VlcDecoder.isAvailable();
+ vlcAvailable = available;
+ }
+ return available ? BackendRefreshResult.RETRY_SUCCEEDED : BackendRefreshResult.RETRY_FAILED;
+ }
+
+ private boolean backendLoaded(String backend) {
+ return VideoBackends.MPV.equals(VideoBackends.normalize(backend))
+ ? MpvVideoBackend.isLoaded()
+ : VlcDecoder.isLoaded();
+ }
+
+ private String backendName(String backend) {
+ return VideoBackends.MPV.equals(VideoBackends.normalize(backend)) ? "MPV" : "VLC";
+ }
+
+ private void finish() {
+ persistProxy();
+ persistYtdlPath();
+ VideoPlayerClient.markStartupGuideShown();
+ if (minecraft != null) {
+ minecraft.gui.setScreen(parent);
+ }
+ }
+
+ private InputRowLayout inputRowLayout() {
+ int rowW = Math.max(1, contentRight - contentLeft);
+ int proxyLabelW = 34;
+ int ytdlLabelW = 36;
+ int inputGap = 6;
+ int pairGap = 8;
+ int fieldSpace = Math.max(80, rowW - proxyLabelW - ytdlLabelW - inputGap * 2 - pairGap);
+ int proxyW = Math.max(40, fieldSpace / 2);
+ int ytdlW = Math.max(40, fieldSpace - proxyW);
+ int proxyFieldX = contentLeft + proxyLabelW + inputGap;
+ int ytdlLabelX = proxyFieldX + proxyW + pairGap;
+ int ytdlFieldX = ytdlLabelX + ytdlLabelW + inputGap;
+ return new InputRowLayout(proxyFieldX, proxyW, ytdlLabelX, ytdlFieldX, ytdlW);
+ }
+
+ private record InputRowLayout(int proxyFieldX, int proxyFieldWidth, int ytdlLabelX, int ytdlFieldX, int ytdlFieldWidth) {
+ }
+
+ private boolean inside(double mouseX, double mouseY, int left, int top, int right, int bottom) {
+ return mouseX >= left && mouseY >= top && mouseX < right && mouseY < bottom;
+ }
+}
diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/VideoCreationEditor.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/VideoCreationEditor.java
new file mode 100644
index 0000000..828f02c
--- /dev/null
+++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/VideoCreationEditor.java
@@ -0,0 +1,1377 @@
+package com.github.squi2rel.vp.creation;
+
+import com.github.squi2rel.vp.ClientPacketHandler;
+import com.github.squi2rel.vp.VideoPlayerClient;
+import com.github.squi2rel.vp.i18n.VpInputTexts;
+import com.github.squi2rel.vp.i18n.VpTexts;
+import com.github.squi2rel.vp.network.RequestResultStatus;
+import com.github.squi2rel.vp.video.ClientVideoArea;
+import com.github.squi2rel.vp.video.ClientVideoScreen;
+import com.github.squi2rel.vp.video.ScreenGeometry;
+import com.github.squi2rel.vp.video.ScreenSurface;
+import com.github.squi2rel.vp.video.VideoScreen;
+import com.mojang.blaze3d.platform.InputConstants;
+import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
+import net.fabricmc.fabric.api.client.keymapping.v1.KeyMappingHelper;
+import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents;
+import net.fabricmc.fabric.api.client.rendering.v1.hud.HudElementRegistry;
+import net.fabricmc.fabric.api.client.rendering.v1.hud.VanillaHudElements;
+import net.fabricmc.fabric.api.event.client.player.ClientPreAttackCallback;
+import net.fabricmc.fabric.api.event.player.UseBlockCallback;
+import net.minecraft.client.KeyMapping;
+import net.minecraft.client.Minecraft;
+import net.minecraft.core.BlockPos;
+import net.minecraft.core.Direction;
+import net.minecraft.network.chat.Component;
+import net.minecraft.resources.Identifier;
+import net.minecraft.world.InteractionHand;
+import net.minecraft.world.InteractionResult;
+import net.minecraft.world.phys.AABB;
+import net.minecraft.world.phys.BlockHitResult;
+import net.minecraft.world.phys.HitResult;
+import net.minecraft.world.phys.Vec3;
+import org.joml.Vector3f;
+import org.lwjgl.glfw.GLFW;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Locale;
+import java.util.function.Consumer;
+
+public final class VideoCreationEditor {
+ private static final Minecraft CLIENT = Minecraft.getInstance();
+ private static final float EPSILON = 0.02f;
+ private static final float SNAP_SCALE = 16.0f;
+ private static final float POINT_NORMAL_OFFSET = 1.0f / 16.0f;
+ private static final double POINT_HIT_RADIUS = 0.10;
+ private static final double GIZMO_HIT_RADIUS = 0.08;
+ private static final double GIZMO_REACH = 64.0;
+ private static final float GIZMO_START = 0.08f;
+ private static final float GIZMO_LENGTH = 0.55f;
+ private static final double DRAG_PLANE_EPSILON = 1.0E-5;
+ private static final Identifier HUD_LAYER = Identifier.fromNamespaceAndPath("videoplayer", "creation_editor");
+ private static final VideoCreationEditor INSTANCE = new VideoCreationEditor();
+
+ private final Draft draft = new Draft();
+ private final ArrayList points = new ArrayList<>();
+
+ private KeyMapping openKey;
+ private boolean selecting;
+ private boolean selectingSpherePreset;
+ private Component status = Component.empty();
+ private boolean statusError;
+ private int selectedPointIndex = -1;
+ private GizmoAxis hoveredAxis;
+ private GizmoAxis draggingAxis;
+ private int draggingPointIndex = -1;
+ private Vector3f dragStartPoint;
+ private Vector3f dragStartIntersection;
+ private Vector3f dragPlaneNormal;
+
+ private VideoCreationEditor() {
+ }
+
+ public static VideoCreationEditor instance() {
+ return INSTANCE;
+ }
+
+ public static void register() {
+ INSTANCE.registerInternal();
+ }
+
+ private void registerInternal() {
+ openKey = KeyMappingHelper.registerKeyMapping(new KeyMapping(
+ "key.videoplayer.creation_editor",
+ InputConstants.Type.KEYSYM,
+ GLFW.GLFW_KEY_V,
+ KeyMapping.Category.register(Identifier.fromNamespaceAndPath("videoplayer", "videoplayer"))
+ ));
+
+ ClientTickEvents.END_CLIENT_TICK.register(this::tick);
+ ClientPreAttackCallback.EVENT.register((client, player, clickCount) -> {
+ if (!selecting) return false;
+ if (clickCount != 0) handlePrimaryClick();
+ return true;
+ });
+ UseBlockCallback.EVENT.register((player, world, hand, hitResult) -> {
+ if (!selecting) return InteractionResult.PASS;
+ if (hand == InteractionHand.MAIN_HAND) {
+ if (draggingAxis != null) {
+ stopDragging();
+ } else {
+ undoLastPoint();
+ }
+ }
+ return InteractionResult.FAIL;
+ });
+ ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> clear());
+ HudElementRegistry.attachElementAfter(
+ VanillaHudElements.CROSSHAIR,
+ HUD_LAYER,
+ SelectionPreviewRenderer::renderHud
+ );
+ }
+
+ private void tick(Minecraft client) {
+ if (client.player == null || client.level == null) {
+ clear();
+ return;
+ }
+ if (selecting && client.gui.screen() == null) {
+ tickSelectionInput();
+ } else {
+ stopDragging();
+ hoveredAxis = null;
+ }
+ while (openKey.consumeClick()) {
+ stopDragging();
+ openConfigScreen();
+ }
+ }
+
+ public Draft draft() {
+ return draft;
+ }
+
+ public List points() {
+ return points;
+ }
+
+ public boolean selecting() {
+ return selecting;
+ }
+
+ public boolean selectingSpherePreset() {
+ return selectingSpherePreset;
+ }
+
+ public boolean active() {
+ return selecting || CLIENT.gui.screen() instanceof VideoManagementScreen || !points.isEmpty();
+ }
+
+ public Component openKeyText() {
+ return Component.keybind(openKey.getName());
+ }
+
+ public Component status() {
+ return status;
+ }
+
+ public boolean statusError() {
+ return statusError;
+ }
+
+ public int selectedPointIndex() {
+ return selectedPointIndex;
+ }
+
+ public SelectionPoint selectedPoint() {
+ return validSelectedPoint() ? points.get(selectedPointIndex) : null;
+ }
+
+ public GizmoAxis hoveredAxis() {
+ return hoveredAxis;
+ }
+
+ public GizmoAxis draggingAxis() {
+ return draggingAxis;
+ }
+
+ public boolean screenGizmoVisible() {
+ return selecting
+ && CLIENT.gui.screen() == null
+ && !selectingSpherePreset
+ && target() == Target.SCREEN
+ && validSelectedPoint();
+ }
+
+ public boolean showCurrentTargetPoint() {
+ if (!selecting) return false;
+ if (hoveredAxis != null || draggingAxis != null) return false;
+ if (hitTestPoint() >= 0) return false;
+ if (selectingSpherePreset || target() == Target.AREA) return points.size() < requiredPoints();
+ if (target() == Target.SCREEN && draft.screenMode == ScreenMode.FREE) {
+ return points.size() < ScreenGeometry.MAX_VERTICES;
+ }
+ return points.size() < requiredPoints();
+ }
+
+ public float gizmoLength() {
+ return GIZMO_LENGTH;
+ }
+
+ public float gizmoStart() {
+ return GIZMO_START;
+ }
+
+ public int requiredPoints() {
+ if (selectingSpherePreset) return 2;
+ if (target() == Target.AREA) return 2;
+ return draft.screenMode == ScreenMode.RECTANGLE ? 2 : ScreenGeometry.MAX_VERTICES;
+ }
+
+ public String pointProgress() {
+ if (selectingSpherePreset) return points.size() + "/" + requiredPoints();
+ if (target() == Target.SCREEN && draft.screenMode == ScreenMode.FREE) {
+ return points.size() + "/" + ScreenGeometry.MAX_VERTICES + " (>= " + ScreenGeometry.MIN_VERTICES + ")";
+ }
+ return points.size() + "/" + requiredPoints();
+ }
+
+ public String modeName() {
+ return modeText().getString();
+ }
+
+ public Component modeText() {
+ if (selectingSpherePreset) return VpTexts.tr("label.videoplayer.mode.sphere_preset", "360 Preset");
+ if (target() == Target.AREA) return VpTexts.tr("label.videoplayer.area", "Area");
+ return switch (draft.operation) {
+ case EDIT_SCREEN_GEOMETRY -> draft.screenMode == ScreenMode.RECTANGLE
+ ? VpTexts.tr("label.videoplayer.mode.edit_screen_rectangle", "Edit Screen Rectangle")
+ : VpTexts.tr("label.videoplayer.mode.edit_screen_free", "Edit Screen Custom");
+ default -> draft.screenMode == ScreenMode.RECTANGLE
+ ? VpTexts.tr("label.videoplayer.mode.screen_rectangle", "Screen Rectangle")
+ : VpTexts.tr("label.videoplayer.mode.screen_free", "Screen Custom");
+ };
+ }
+
+ public void openConfigScreen() {
+ openConfigScreen(null);
+ }
+
+ public void openConfigScreen(ClientVideoScreen selectedScreen) {
+ ensureDefaults();
+ CLIENT.gui.setScreen(new VideoManagementScreen(this, selecting ? null : selectedScreen));
+ }
+
+ public void beginSelection(Draft updated) {
+ draft.copyFrom(updated);
+ if (!validateDraft(false)) return;
+ points.clear();
+ resetGizmoState();
+ selecting = true;
+ selectingSpherePreset = false;
+ if (target() == Target.SCREEN && draft.screenMode == ScreenMode.FREE) {
+ setStatusWithOpenKey("status.videoplayer.add_vertices", "%1$s to add vertices, at least 3. %2$s undo, %3$s returns to confirm", false,
+ leftMouseText(), rightMouseText());
+ } else {
+ setStatusWithInput("status.videoplayer.select_point", "%1$s to select point %2$s", false, leftMouseText(), 1);
+ }
+ CLIENT.gui.setScreen(null);
+ }
+
+ public void beginSpherePresetSelection(Draft updated) {
+ draft.copyFrom(updated);
+ if (!validateDraft(false)) return;
+ points.clear();
+ resetGizmoState();
+ selecting = true;
+ selectingSpherePreset = true;
+ setStatusWithInput("status.videoplayer.select_sphere_center", "%1$s to select sphere center, then select radius point", false, leftMouseText());
+ CLIENT.gui.setScreen(null);
+ }
+
+ public void clearSelection() {
+ selecting = false;
+ selectingSpherePreset = false;
+ points.clear();
+ resetGizmoState();
+ setStatus("status.videoplayer.selection_cleared", "Current selection cleared", false);
+ }
+
+ public void clear() {
+ selecting = false;
+ selectingSpherePreset = false;
+ points.clear();
+ resetGizmoState();
+ status = Component.empty();
+ statusError = false;
+ }
+
+ public boolean ready() {
+ if (target() == Target.AREA) return areaPreview() != null && points.size() >= 2;
+ return completeVertices() != null;
+ }
+
+ public boolean confirm() {
+ return confirm(null);
+ }
+
+ public boolean confirm(Consumer callback) {
+ if (!validateDraft(true)) return false;
+ if (draft.operation == Operation.CREATE_AREA) {
+ AABB box = areaPreview();
+ if (box == null) {
+ setStatus("error.videoplayer.select_two_blocks", "Select two blocks first", true);
+ return false;
+ }
+ ClientPacketHandler.createArea(
+ new Vector3f((float) box.minX, (float) box.minY, (float) box.minZ),
+ new Vector3f((float) box.maxX, (float) box.maxY, (float) box.maxZ),
+ draft.name.trim(),
+ result -> handleSubmitResult(result, callback)
+ );
+ return true;
+ }
+
+ ClientVideoArea area = VideoPlayerClient.areas.get(draft.areaName.trim());
+ List vertices = completeVertices();
+ if (area == null || vertices == null) {
+ setStatus("error.videoplayer.screen_selection_incomplete", "Screen selection is incomplete", true);
+ return false;
+ }
+ if (!screenInsideArea(area, vertices)) {
+ setStatus("error.videoplayer.screen_vertices_outside_area", "Screen vertices must be inside the Area", true);
+ return false;
+ }
+ if (draft.surface == ScreenSurface.SPHERE_360 && !sphereReady()) {
+ setStatus("error.videoplayer.sphere_preset_required", "Define 360 parameters first", true);
+ return false;
+ }
+ if (draft.spherePreset && !spherePresetInsideArea(area)) {
+ setStatus("error.videoplayer.sphere_center_outside_area", "360 sphere center must be inside the Area", true);
+ return false;
+ }
+ if (draft.operation == Operation.CREATE_SCREEN) {
+ VideoScreen screen = new VideoScreen(
+ area,
+ draft.name.trim(),
+ vertices,
+ draft.source.trim()
+ );
+ applyDraftDisplay(screen);
+ ClientPacketHandler.createScreen(screen, result -> handleSubmitResult(result, callback));
+ } else {
+ ClientVideoScreen screen = area.getScreen(draft.name.trim());
+ if (screen == null) {
+ setStatus("error.videoplayer.select_loaded_screen", "Select a loaded Screen", true);
+ return false;
+ }
+ VideoScreen displayConfig = new VideoScreen(area, screen.name, vertices, draft.source.trim());
+ applyDraftDisplay(displayConfig);
+ ClientPacketHandler.updateScreen(screen, vertices, draft.source.trim(), displayConfig, result -> handleSubmitResult(result, callback));
+ }
+ return true;
+ }
+
+ private void handleSubmitResult(ClientPacketHandler.RequestResult result, Consumer callback) {
+ if (result == null) {
+ setStatus("error.videoplayer.request_incomplete", "Request did not complete", true);
+ } else {
+ boolean ok = result.status() == RequestResultStatus.OK;
+ if (result.message() != null && !result.message().isEmpty()) {
+ setStatus(VpTexts.text(result.message()), !ok);
+ } else if (ok) {
+ setStatus("status.videoplayer.request_completed", "Request completed", false);
+ } else if (result.status() == RequestResultStatus.DENIED) {
+ setStatus("error.videoplayer.permission_denied", "Permission denied", true);
+ } else {
+ setStatus("error.videoplayer.request_incomplete", "Request did not complete", true);
+ }
+ }
+ if (result != null && result.status() == RequestResultStatus.OK) {
+ clearAfterSubmit();
+ }
+ if (callback != null) callback.accept(result);
+ }
+
+ private void clearAfterSubmit() {
+ selecting = false;
+ selectingSpherePreset = false;
+ points.clear();
+ resetGizmoState();
+ if (draft.operation != Operation.EDIT_SCREEN_GEOMETRY) {
+ draft.name = "";
+ }
+ ensureDefaults();
+ }
+
+ public List areaNames() {
+ return VideoPlayerClient.areas.values().stream()
+ .map(area -> area.name)
+ .sorted()
+ .toList();
+ }
+
+ public List realScreenNames(String areaName) {
+ ClientVideoArea area = VideoPlayerClient.areas.get(areaName);
+ if (area == null) return List.of();
+ return area.screens.stream()
+ .filter(screen -> screen.source == null || screen.source.isEmpty())
+ .map(screen -> screen.name)
+ .sorted()
+ .toList();
+ }
+
+ public String suggestedAreaName() {
+ return uniqueAreaName();
+ }
+
+ public String suggestedScreenName(String areaName) {
+ return uniqueScreenName(areaName);
+ }
+
+ public AABB areaPreview() {
+ if (target() != Target.AREA) return null;
+ if (points.size() >= 2) {
+ return areaBox(points.get(0).blockPos, points.get(1).blockPos);
+ }
+ if (selecting && points.size() == 1) {
+ BlockHitResult target = currentBlockHit();
+ if (target != null) return areaBox(points.getFirst().blockPos, target.getBlockPos());
+ }
+ return null;
+ }
+
+ public List previewVertices() {
+ if (target() != Target.SCREEN) return null;
+ if (draft.screenMode == ScreenMode.RECTANGLE) {
+ if (points.size() >= 2) return asList(rectangleQuad(points.get(0), points.get(1)));
+ if (showCurrentTargetPoint() && points.size() == 1) {
+ BlockHitResult target = currentBlockHit();
+ if (target != null) return asList(rectangleQuad(points.getFirst(), selectionPointFrom(target)));
+ }
+ return null;
+ }
+
+ ArrayList preview = new ArrayList<>(points);
+ if (showCurrentTargetPoint() && points.size() < ScreenGeometry.MAX_VERTICES) {
+ BlockHitResult target = currentBlockHit();
+ if (target != null) preview.add(selectionPointFrom(target));
+ }
+ if (preview.size() >= ScreenGeometry.MIN_VERTICES) return freeVertices(preview, false);
+ return null;
+ }
+
+ public List completeVertices() {
+ if (target() != Target.SCREEN) return null;
+ if (draft.screenMode == ScreenMode.RECTANGLE) {
+ return points.size() >= 2 ? asList(rectangleQuad(points.get(0), points.get(1))) : null;
+ }
+ return points.size() >= ScreenGeometry.MIN_VERTICES ? freeVertices(points, false) : null;
+ }
+
+ public SelectionPoint currentTargetPoint() {
+ BlockHitResult target = currentBlockHit();
+ return target == null ? null : selectionPointFrom(target);
+ }
+
+ public void ensureDefaults() {
+ if (draft.operation == null) {
+ draft.operation = draft.target == Target.SCREEN ? Operation.CREATE_SCREEN : Operation.CREATE_AREA;
+ }
+ draft.target = draft.operation.target();
+ if (draft.target == null) draft.target = Target.AREA;
+ if (draft.screenMode == null) draft.screenMode = ScreenMode.RECTANGLE;
+ if (draft.surface == null) draft.surface = ScreenSurface.FLAT;
+ if (draft.sphereRadius <= 0 || !Float.isFinite(draft.sphereRadius)) draft.sphereRadius = 10;
+ draft.sphereLat = VideoScreen.clampSphereSegments(draft.sphereLat);
+ draft.sphereLon = VideoScreen.clampSphereSegments(draft.sphereLon);
+ if (!Float.isFinite(draft.sphereRotX)) draft.sphereRotX = 0;
+ if (!Float.isFinite(draft.sphereRotY)) draft.sphereRotY = 0;
+ if (!Float.isFinite(draft.sphereRotZ)) draft.sphereRotZ = 0;
+ if (draft.areaName == null) draft.areaName = "";
+ if (draft.name == null) draft.name = "";
+ if (draft.source == null) draft.source = "";
+
+ if (target() == Target.SCREEN) {
+ if (!VideoPlayerClient.areas.containsKey(draft.areaName)) {
+ draft.areaName = areaNames().stream().findFirst().orElse("");
+ }
+ if (draft.operation == Operation.CREATE_SCREEN && draft.name.isBlank()) {
+ draft.name = uniqueScreenName(draft.areaName);
+ } else if (draft.operation == Operation.EDIT_SCREEN_GEOMETRY && !draft.areaName.isBlank()) {
+ ClientVideoArea area = VideoPlayerClient.areas.get(draft.areaName);
+ if (area != null && (draft.name.isBlank() || area.getScreen(draft.name) == null)) {
+ draft.name = area.screens.stream().map(screen -> screen.name).sorted().findFirst().orElse("");
+ }
+ }
+ } else if (draft.name.isBlank()) {
+ draft.name = uniqueAreaName();
+ }
+ }
+
+ private boolean validateDraft(boolean requireSelection) {
+ if (!VideoPlayerClient.connected && !VideoPlayerClient.config.alwaysConnected) {
+ setStatus("error.videoplayer.not_connected", "Not connected to server", true);
+ return false;
+ }
+ String name = draft.name.trim();
+ if (name.isEmpty()) {
+ setStatus("error.videoplayer.name_empty", "Name must not be empty", true);
+ return false;
+ }
+ if (!VideoScreen.validName(name)) {
+ setStatus(
+ "error.videoplayer.name_invalid_length_plain",
+ "Name must not exceed %s Unicode characters or %s UTF-8 bytes",
+ true,
+ VideoScreen.MAX_NAME_LENGTH,
+ VideoScreen.MAX_NAME_BYTES
+ );
+ return false;
+ }
+ if (draft.operation == Operation.CREATE_AREA) {
+ if (VideoPlayerClient.areas.containsKey(name)) {
+ setStatus("error.videoplayer.area_same_name_exists", "An Area with the same name already exists", true);
+ return false;
+ }
+ if (requireSelection && areaPreview() == null) {
+ setStatus("error.videoplayer.select_two_blocks", "Select two blocks first", true);
+ return false;
+ }
+ return true;
+ }
+
+ ClientVideoArea area = VideoPlayerClient.areas.get(draft.areaName.trim());
+ if (area == null) {
+ setStatus("error.videoplayer.select_loaded_area", "Select a loaded Area", true);
+ return false;
+ }
+ ClientVideoScreen existingScreen = area.getScreen(name);
+ if (draft.operation == Operation.CREATE_SCREEN && existingScreen != null) {
+ setStatus("error.videoplayer.screen_same_name_exists", "This Area already contains a Screen with the same name", true);
+ return false;
+ }
+ if (draft.operation == Operation.EDIT_SCREEN_GEOMETRY && existingScreen == null) {
+ setStatus("error.videoplayer.select_loaded_screen", "Select a loaded Screen", true);
+ return false;
+ }
+ String source = draft.source.trim();
+ if (!source.isEmpty() && source.equals(name)) {
+ setStatus("error.videoplayer.source_screen_self", "Source Screen cannot point to itself", true);
+ return false;
+ }
+ if (!source.isEmpty() && area.getScreen(source) == null) {
+ setStatus("error.videoplayer.source_screen_missing", "Source Screen does not exist", true);
+ return false;
+ }
+ if (draft.surface == ScreenSurface.SPHERE_360 && !sphereReady()) {
+ setStatus("error.videoplayer.sphere_preset_required", "Define 360 parameters first", true);
+ return false;
+ }
+ if (draft.spherePreset && !spherePresetInsideArea(area)) {
+ setStatus("error.videoplayer.sphere_center_outside_area", "360 sphere center must be inside the Area", true);
+ return false;
+ }
+ if (requireSelection) {
+ Component previousStatus = status;
+ boolean previousError = statusError;
+ if (completeVerticesForSubmit() == null) {
+ if (status == previousStatus && statusError == previousError) {
+ setStatus("error.videoplayer.complete_screen_points", "Complete screen point selection", true);
+ }
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private void handlePrimaryClick() {
+ if (handleScreenPointClick()) return;
+ if (handleExistingPointClick()) return;
+ selectCurrentTarget();
+ }
+
+ private boolean handleScreenPointClick() {
+ if (!screenPointEditingEnabled()) return false;
+ if (draggingAxis != null) return true;
+ updateHoveredAxis();
+ if (hoveredAxis != null && beginDragging(hoveredAxis)) {
+ return true;
+ }
+ int pointIndex = hitTestPoint();
+ if (pointIndex >= 0) {
+ selectedPointIndex = pointIndex;
+ setStatus("status.videoplayer.point_selected_with_gizmo", "Selected point %s. Drag X/Y/Z arrows to adjust", false, pointIndex + 1);
+ return true;
+ }
+ return false;
+ }
+
+ private boolean handleExistingPointClick() {
+ if (!selecting || CLIENT.gui.screen() != null || points.isEmpty()) return false;
+ int pointIndex = hitTestPoint();
+ if (pointIndex < 0) return false;
+ selectedPointIndex = pointIndex;
+ setStatus("status.videoplayer.point_selected", "Selected point %s", false, pointIndex + 1);
+ return true;
+ }
+
+ private void selectCurrentTarget() {
+ if (selectionPointLimitReached()) {
+ if (target() == Target.SCREEN && !selectingSpherePreset) {
+ setStatusWithOpenKey("status.videoplayer.vertex_limit_reached", "Vertex limit reached. Drag arrows to adjust, press %2$s to return and confirm, %1$s undo to keep selecting", false,
+ rightMouseText());
+ } else {
+ setStatusWithOpenKey("status.videoplayer.selection_complete_adjustable", "Selection complete. Press %2$s to return and confirm, %1$s undo to adjust", false,
+ rightMouseText());
+ openConfigScreen();
+ }
+ return;
+ }
+ BlockHitResult target = currentBlockHit();
+ if (target == null) {
+ setStatusWithInput("error.videoplayer.look_at_block", "Look at a block, then %1$s to select a point", true, leftMouseText());
+ return;
+ }
+ SelectionPoint point = selectionPointFrom(target);
+ if (target() == Target.SCREEN && draft.screenMode == ScreenMode.FREE && points.size() >= ScreenGeometry.MAX_VERTICES) {
+ setStatus("error.videoplayer.vertex_limit", "Vertex count has reached the limit %s", true, ScreenGeometry.MAX_VERTICES);
+ return;
+ }
+ points.add(point);
+ if (target() == Target.SCREEN && !selectingSpherePreset) {
+ selectedPointIndex = points.size() - 1;
+ }
+ if (selectingSpherePreset) {
+ updateSphereDraftFromPoints();
+ if (points.size() >= requiredPoints()) {
+ selecting = false;
+ selectingSpherePreset = false;
+ points.clear();
+ setStatus("status.videoplayer.sphere_preset_updated", "360 preset updated", false);
+ openConfigScreen();
+ return;
+ }
+ setStatusWithInput("status.videoplayer.select_radius_point", "%1$s to select radius point", false, leftMouseText());
+ return;
+ }
+ if (target() == Target.SCREEN && draft.screenMode == ScreenMode.FREE) {
+ List vertices = completeVerticesForSubmit();
+ if (points.size() < ScreenGeometry.MIN_VERTICES) {
+ setStatusWithInput("status.videoplayer.select_free_point_min", "%1$s to select point %2$s. At least %3$s points are required. Current point can be adjusted with arrows", false,
+ leftMouseText(), points.size() + 1, ScreenGeometry.MIN_VERTICES);
+ return;
+ }
+ if (vertices == null) {
+ return;
+ }
+ if (points.size() >= ScreenGeometry.MAX_VERTICES) {
+ setStatusWithOpenKey("status.videoplayer.selection_complete_drag", "Selection complete. Drag arrows to adjust, press %1$s to return and confirm", false);
+ return;
+ }
+ setStatusWithOpenKey("status.videoplayer.free_points_selected", "Selected %1$s vertices. Press %2$s to return and confirm, keep %3$s to add points. Current point can be adjusted with arrows", false,
+ points.size(), leftMouseText());
+ return;
+ }
+ if (points.size() >= requiredPoints()) {
+ if (target() == Target.SCREEN && completeVerticesForSubmit() == null) {
+ setStatusWithInput("error.videoplayer.screen_points_invalid_adjust", "Screen points are invalid. Drag arrows to adjust or %1$s undo", true, rightMouseText());
+ return;
+ }
+ if (target() == Target.SCREEN) {
+ setStatusWithOpenKey("status.videoplayer.selection_complete_drag", "Selection complete. Drag arrows to adjust, press %1$s to return and confirm", false);
+ } else {
+ setStatusWithOpenKey("status.videoplayer.selection_complete", "Selection complete. Press %1$s to return and confirm", false);
+ openConfigScreen();
+ }
+ return;
+ }
+ setStatusWithInput("status.videoplayer.select_point", "%1$s to select point %2$s", false, leftMouseText(), points.size() + 1);
+ }
+
+ private boolean selectionPointLimitReached() {
+ if (selectingSpherePreset) return points.size() >= requiredPoints();
+ if (target() == Target.SCREEN && draft.screenMode == ScreenMode.FREE) {
+ return points.size() >= ScreenGeometry.MAX_VERTICES;
+ }
+ return points.size() >= requiredPoints();
+ }
+
+ private void undoLastPoint() {
+ stopDragging();
+ if (points.isEmpty()) {
+ setStatus("status.videoplayer.no_point_to_undo", "There are no points to undo", false);
+ return;
+ }
+ points.removeLast();
+ selectedPointIndex = Math.min(selectedPointIndex, points.size() - 1);
+ if (selectedPointIndex < 0 && !points.isEmpty() && target() == Target.SCREEN && !selectingSpherePreset) {
+ selectedPointIndex = points.size() - 1;
+ }
+ if (selectingSpherePreset) {
+ updateSphereDraftFromPoints();
+ setStatusWithInput("status.videoplayer.undo_select_point", "Undone. %1$s to select point %2$s", false, leftMouseText(), points.size() + 1);
+ return;
+ }
+ if (target() == Target.SCREEN && draft.screenMode == ScreenMode.FREE) {
+ setStatusWithOpenKey("status.videoplayer.undo_free_points", "Undone. Current vertices: %1$s. %2$s to continue, %3$s to return", false,
+ points.size(), leftMouseText());
+ return;
+ }
+ setStatusWithInput("status.videoplayer.undo_select_point", "Undone. %1$s to select point %2$s", false, leftMouseText(), points.size() + 1);
+ }
+
+ private BlockHitResult currentBlockHit() {
+ HitResult target = CLIENT.hitResult;
+ if (target == null || target.getType() != HitResult.Type.BLOCK) return null;
+ return (BlockHitResult) target;
+ }
+
+ private SelectionPoint selectionPointFrom(BlockHitResult hit) {
+ return SelectionPoint.from(hit, selectingSpherePreset || target() == Target.SCREEN);
+ }
+
+ private void tickSelectionInput() {
+ if (!screenPointEditingEnabled()) {
+ stopDragging();
+ hoveredAxis = null;
+ return;
+ }
+ if (draggingAxis != null) {
+ if (leftMousePressed()) {
+ dragSelectedPoint();
+ } else {
+ stopDragging();
+ updateHoveredAxis();
+ }
+ return;
+ }
+ updateHoveredAxis();
+ }
+
+ private boolean leftMousePressed() {
+ return GLFW.glfwGetMouseButton(CLIENT.getWindow().handle(), GLFW.GLFW_MOUSE_BUTTON_LEFT) == GLFW.GLFW_PRESS;
+ }
+
+ private boolean screenPointEditingEnabled() {
+ return selecting
+ && CLIENT.gui.screen() == null
+ && !selectingSpherePreset
+ && target() == Target.SCREEN
+ && !points.isEmpty();
+ }
+
+ private boolean validSelectedPoint() {
+ return selectedPointIndex >= 0 && selectedPointIndex < points.size();
+ }
+
+ private void updateHoveredAxis() {
+ hoveredAxis = hitTestGizmo();
+ }
+
+ private int hitTestPoint() {
+ Ray ray = currentRay();
+ if (ray == null) return -1;
+
+ double bestDistance = POINT_HIT_RADIUS * POINT_HIT_RADIUS;
+ int bestIndex = -1;
+ for (int i = 0; i < points.size(); i++) {
+ double distance = distanceRayPointSq(ray, points.get(i).point);
+ if (distance <= bestDistance) {
+ bestDistance = distance;
+ bestIndex = i;
+ }
+ }
+ return bestIndex;
+ }
+
+ private GizmoAxis hitTestGizmo() {
+ if (!validSelectedPoint()) return null;
+ Ray ray = currentRay();
+ if (ray == null) return null;
+
+ Vector3f point = points.get(selectedPointIndex).point;
+ double bestDistance = GIZMO_HIT_RADIUS * GIZMO_HIT_RADIUS;
+ GizmoAxis bestAxis = null;
+ for (GizmoAxis axis : GizmoAxis.values()) {
+ Vector3f axisVector = axis.vector();
+ Vector3f start = new Vector3f(point).add(new Vector3f(axisVector).mul(GIZMO_START));
+ Vector3f end = new Vector3f(point).add(new Vector3f(axisVector).mul(GIZMO_LENGTH));
+ double distance = distanceRaySegmentSq(ray, start, end);
+ if (distance <= bestDistance) {
+ bestDistance = distance;
+ bestAxis = axis;
+ }
+ }
+ return bestAxis;
+ }
+
+ private boolean beginDragging(GizmoAxis axis) {
+ if (!validSelectedPoint()) return false;
+ Vector3f point = points.get(selectedPointIndex).point;
+ Vector3f normal = createDragPlaneNormal(axis, point);
+ Vector3f intersection = intersectDragPlane(point, normal);
+ if (intersection == null) return false;
+
+ draggingAxis = axis;
+ draggingPointIndex = selectedPointIndex;
+ dragStartPoint = new Vector3f(point);
+ dragStartIntersection = intersection;
+ dragPlaneNormal = normal;
+ hoveredAxis = axis;
+ setStatus("status.videoplayer.drag_axis", "Dragging %s axis. Coordinates snap to 1/16 block", false, axis.label());
+ return true;
+ }
+
+ private void dragSelectedPoint() {
+ if (draggingAxis == null || draggingPointIndex < 0 || draggingPointIndex >= points.size()) {
+ stopDragging();
+ return;
+ }
+ Vector3f intersection = intersectDragPlane(dragStartPoint, dragPlaneNormal);
+ if (intersection == null) return;
+
+ Vector3f axisVector = draggingAxis.vector();
+ float along = new Vector3f(intersection).sub(dragStartIntersection).dot(axisVector);
+ Vector3f next = new Vector3f(dragStartPoint).add(new Vector3f(axisVector).mul(along));
+ snapPoint(next);
+ points.get(draggingPointIndex).point.set(next);
+ selectedPointIndex = draggingPointIndex;
+ updateMovedPointStatus();
+ }
+
+ private void updateMovedPointStatus() {
+ if (!validSelectedPoint()) return;
+ boolean enoughPoints = draft.screenMode == ScreenMode.RECTANGLE
+ ? points.size() >= 2
+ : points.size() >= ScreenGeometry.MIN_VERTICES;
+ if (enoughPoints && completeVerticesForSubmit() == null) {
+ if (!statusError) {
+ setStatus("error.videoplayer.screen_points_invalid_continue", "Screen points are invalid. Continue adjusting", true);
+ }
+ return;
+ }
+ setStatusWithOpenKey("status.videoplayer.point_position", "Point %1$s %2$s. Press %3$s to return and confirm", false, selectedPointIndex + 1, points.get(selectedPointIndex).format());
+ }
+
+ private void stopDragging() {
+ draggingAxis = null;
+ draggingPointIndex = -1;
+ dragStartPoint = null;
+ dragStartIntersection = null;
+ dragPlaneNormal = null;
+ }
+
+ private void resetGizmoState() {
+ selectedPointIndex = -1;
+ hoveredAxis = null;
+ stopDragging();
+ }
+
+ private Vector3f createDragPlaneNormal(GizmoAxis axis, Vector3f point) {
+ Vec3 eye = CLIENT.player == null ? new Vec3(point.x, point.y, point.z) : CLIENT.player.getEyePosition();
+ Vector3f toCamera = new Vector3f((float) (eye.x - point.x), (float) (eye.y - point.y), (float) (eye.z - point.z));
+ if (toCamera.lengthSquared() < EPSILON * EPSILON) {
+ toCamera.set(0, 0, 1);
+ } else {
+ toCamera.normalize();
+ }
+
+ Vector3f axisVector = axis.vector();
+ Vector3f normal = new Vector3f(toCamera).sub(new Vector3f(axisVector).mul(toCamera.dot(axisVector)));
+ if (normal.lengthSquared() < EPSILON * EPSILON) {
+ normal = axis == GizmoAxis.Y ? new Vector3f(1, 0, 0) : new Vector3f(0, 1, 0);
+ normal.sub(new Vector3f(axisVector).mul(normal.dot(axisVector)));
+ }
+ return normal.normalize();
+ }
+
+ private Vector3f intersectDragPlane(Vector3f planePoint, Vector3f planeNormal) {
+ if (planePoint == null || planeNormal == null) return null;
+ Ray ray = currentRay();
+ if (ray == null) return null;
+ double denominator = planeNormal.x * ray.direction.x + planeNormal.y * ray.direction.y + planeNormal.z * ray.direction.z;
+ if (Math.abs(denominator) < DRAG_PLANE_EPSILON) return null;
+
+ double t = ((planePoint.x - ray.origin.x) * planeNormal.x
+ + (planePoint.y - ray.origin.y) * planeNormal.y
+ + (planePoint.z - ray.origin.z) * planeNormal.z) / denominator;
+ if (t < 0 || t > GIZMO_REACH) return null;
+ return new Vector3f(
+ (float) (ray.origin.x + ray.direction.x * t),
+ (float) (ray.origin.y + ray.direction.y * t),
+ (float) (ray.origin.z + ray.direction.z * t)
+ );
+ }
+
+ private Ray currentRay() {
+ if (CLIENT.player == null) return null;
+ Vec3 direction = CLIENT.player.getViewVector(1.0f);
+ if (direction.lengthSqr() <= 0) return null;
+ return new Ray(CLIENT.player.getEyePosition(), direction.normalize());
+ }
+
+ private double distanceRayPointSq(Ray ray, Vector3f point) {
+ Vec3 target = toVec3d(point);
+ Vec3 toTarget = target.subtract(ray.origin);
+ double along = toTarget.dot(ray.direction);
+ if (along < 0 || along > GIZMO_REACH) return Double.POSITIVE_INFINITY;
+ Vec3 closest = ray.origin.add(ray.direction.scale(along));
+ return target.distanceToSqr(closest);
+ }
+
+ private double distanceRaySegmentSq(Ray ray, Vector3f start, Vector3f end) {
+ Vec3 rayStart = ray.origin;
+ Vec3 rayEnd = ray.origin.add(ray.direction.scale(GIZMO_REACH));
+ return distanceSegmentSegmentSq(rayStart, rayEnd, toVec3d(start), toVec3d(end));
+ }
+
+ private double distanceSegmentSegmentSq(Vec3 p1, Vec3 q1, Vec3 p2, Vec3 q2) {
+ Vec3 d1 = q1.subtract(p1);
+ Vec3 d2 = q2.subtract(p2);
+ Vec3 r = p1.subtract(p2);
+ double a = d1.dot(d1);
+ double e = d2.dot(d2);
+ double f = d2.dot(r);
+ double s;
+ double t;
+
+ if (a <= DRAG_PLANE_EPSILON && e <= DRAG_PLANE_EPSILON) {
+ return p1.distanceToSqr(p2);
+ }
+ if (a <= DRAG_PLANE_EPSILON) {
+ s = 0;
+ t = clamp(f / e, 0, 1);
+ } else {
+ double c = d1.dot(r);
+ if (e <= DRAG_PLANE_EPSILON) {
+ t = 0;
+ s = clamp(-c / a, 0, 1);
+ } else {
+ double b = d1.dot(d2);
+ double denominator = a * e - b * b;
+ if (Math.abs(denominator) > DRAG_PLANE_EPSILON) {
+ s = clamp((b * f - c * e) / denominator, 0, 1);
+ } else {
+ s = 0;
+ }
+ t = (b * s + f) / e;
+ if (t < 0) {
+ t = 0;
+ s = clamp(-c / a, 0, 1);
+ } else if (t > 1) {
+ t = 1;
+ s = clamp((b - c) / a, 0, 1);
+ }
+ }
+ }
+
+ Vec3 closest1 = p1.add(d1.scale(s));
+ Vec3 closest2 = p2.add(d2.scale(t));
+ return closest1.distanceToSqr(closest2);
+ }
+
+ private Vec3 toVec3d(Vector3f point) {
+ return new Vec3(point.x, point.y, point.z);
+ }
+
+ private double clamp(double value, double min, double max) {
+ return Math.max(min, Math.min(max, value));
+ }
+
+ private static void snapPoint(Vector3f point) {
+ point.set(snap(point.x), snap(point.y), snap(point.z));
+ }
+
+ private static float snap(float value) {
+ return Math.round(value * SNAP_SCALE) / SNAP_SCALE;
+ }
+
+ private AABB areaBox(BlockPos a, BlockPos b) {
+ int minX = Math.min(a.getX(), b.getX());
+ int minY = Math.min(a.getY(), b.getY());
+ int minZ = Math.min(a.getZ(), b.getZ());
+ int maxX = Math.max(a.getX(), b.getX()) + 1;
+ int maxY = Math.max(a.getY(), b.getY()) + 1;
+ int maxZ = Math.max(a.getZ(), b.getZ()) + 1;
+ return new AABB(minX, minY, minZ, maxX, maxY, maxZ);
+ }
+
+ private Vector3f[] rectangleQuad(SelectionPoint first, SelectionPoint second) {
+ Vector3f normal = directionVector(first.side);
+ Vector3f p1 = new Vector3f(first.point);
+ Vector3f p3 = projectToPlane(second.point, p1, normal);
+ Vector3f delta = new Vector3f(p3).sub(p1);
+ Vector3f right = screenRight(first.side);
+ Vector3f down = screenDown(first.side);
+ float dr = delta.dot(right);
+ float dd = delta.dot(down);
+ if (Math.abs(dr) < EPSILON || Math.abs(dd) < EPSILON) return null;
+
+ float minR = Math.min(0, dr);
+ float maxR = Math.max(0, dr);
+ float minD = Math.min(0, dd);
+ float maxD = Math.max(0, dd);
+
+ Vector3f topLeft = rectanglePoint(p1, right, down, minR, minD);
+ Vector3f topRight = rectanglePoint(p1, right, down, maxR, minD);
+ Vector3f bottomRight = rectanglePoint(p1, right, down, maxR, maxD);
+ Vector3f bottomLeft = rectanglePoint(p1, right, down, minR, maxD);
+ return transformRectangle(new Vector3f[]{topLeft, topRight, bottomRight, bottomLeft});
+ }
+
+ private List completeVerticesForSubmit() {
+ if (target() != Target.SCREEN) return null;
+ if (draft.screenMode == ScreenMode.RECTANGLE) return completeVertices();
+ return points.size() >= ScreenGeometry.MIN_VERTICES ? freeVertices(points, true) : null;
+ }
+
+ private List freeVertices(List sourcePoints, boolean updateStatus) {
+ if (sourcePoints.size() < ScreenGeometry.MIN_VERTICES || sourcePoints.size() > ScreenGeometry.MAX_VERTICES) {
+ return null;
+ }
+ ArrayList vertices = new ArrayList<>(sourcePoints.size());
+ for (SelectionPoint point : sourcePoints) {
+ vertices.add(new Vector3f(point.point));
+ }
+ try {
+ ScreenGeometry.create(vertices);
+ return vertices;
+ } catch (IllegalArgumentException e) {
+ if (updateStatus) setStatus("error.videoplayer.screen_points_invalid_reason", "Screen points are invalid: %s", true, e.getMessage());
+ return null;
+ }
+ }
+
+ private List asList(Vector3f[] vertices) {
+ if (vertices == null) return null;
+ return List.of(vertices);
+ }
+
+ private Vector3f projectToPlane(Vector3f point, Vector3f planePoint, Vector3f normal) {
+ float distance = new Vector3f(point).sub(planePoint).dot(normal);
+ return new Vector3f(point).sub(new Vector3f(normal).mul(distance));
+ }
+
+ private Vector3f directionVector(Direction direction) {
+ return new Vector3f(direction.getStepX(), direction.getStepY(), direction.getStepZ());
+ }
+
+ private Vector3f rectanglePoint(Vector3f origin, Vector3f right, Vector3f down, float r, float d) {
+ return new Vector3f(origin)
+ .add(new Vector3f(right).mul(r))
+ .add(new Vector3f(down).mul(d));
+ }
+
+ private Vector3f screenRight(Direction side) {
+ if (side.getAxis() == Direction.Axis.Y) {
+ return new Vector3f(1, 0, 0);
+ }
+ Vector3f up = new Vector3f(0, 1, 0);
+ return up.cross(directionVector(side)).normalize();
+ }
+
+ private Vector3f screenDown(Direction side) {
+ if (side == Direction.UP) {
+ return new Vector3f(0, 0, 1);
+ }
+ if (side == Direction.DOWN) {
+ return new Vector3f(0, 0, -1);
+ }
+ return new Vector3f(0, -1, 0);
+ }
+
+ private Vector3f[] transformRectangle(Vector3f[] corners) {
+ Vector3f[] transformed = corners.clone();
+ int rotation = Math.floorMod(draft.rectangleRotation, 4);
+ for (int i = 0; i < rotation; i++) {
+ transformed = new Vector3f[]{transformed[3], transformed[0], transformed[1], transformed[2]};
+ }
+ if (draft.rectangleFlipHorizontal) {
+ transformed = new Vector3f[]{transformed[1], transformed[0], transformed[3], transformed[2]};
+ }
+ if (draft.rectangleFlipVertical) {
+ transformed = new Vector3f[]{transformed[3], transformed[2], transformed[1], transformed[0]};
+ }
+ return transformed;
+ }
+
+ private boolean screenInsideArea(ClientVideoArea area, List vertices) {
+ for (Vector3f point : vertices) {
+ if (point.x < area.min.x - EPSILON || point.y < area.min.y - EPSILON || point.z < area.min.z - EPSILON) {
+ return false;
+ }
+ if (point.x > area.max.x + EPSILON || point.y > area.max.y + EPSILON || point.z > area.max.z + EPSILON) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private boolean spherePresetInsideArea(ClientVideoArea area) {
+ return draft.sphereCenter != null
+ && draft.sphereCenter.x >= area.min.x - EPSILON && draft.sphereCenter.y >= area.min.y - EPSILON && draft.sphereCenter.z >= area.min.z - EPSILON
+ && draft.sphereCenter.x <= area.max.x + EPSILON && draft.sphereCenter.y <= area.max.y + EPSILON && draft.sphereCenter.z <= area.max.z + EPSILON;
+ }
+
+ public Vector3f spherePreviewCenter() {
+ if (target() != Target.SCREEN || !selectingSpherePreset) return null;
+ if (!points.isEmpty()) return new Vector3f(points.getFirst().point);
+ return draft.sphereCenter == null ? null : new Vector3f(draft.sphereCenter);
+ }
+
+ public float spherePreviewRadius() {
+ if (target() != Target.SCREEN || !selectingSpherePreset) return 0;
+ if (points.size() >= 2) return new Vector3f(points.get(1).point).sub(points.getFirst().point).length();
+ if (selectingSpherePreset && points.size() == 1) {
+ SelectionPoint current = currentTargetPoint();
+ if (current != null) return new Vector3f(current.point).sub(points.getFirst().point).length();
+ }
+ return draft.sphereRadius;
+ }
+
+ private boolean sphereReady() {
+ return draft.spherePreset && draft.sphereCenter != null && Float.isFinite(draft.sphereRadius) && draft.sphereRadius > EPSILON;
+ }
+
+ private void updateSphereDraftFromPoints() {
+ if (points.isEmpty()) {
+ draft.spherePreset = false;
+ return;
+ }
+ draft.spherePreset = true;
+ draft.sphereCenter = new Vector3f(points.getFirst().point);
+ if (points.size() >= 2) {
+ draft.sphereRadius = Math.max(EPSILON, new Vector3f(points.get(1).point).sub(points.getFirst().point).length());
+ }
+ }
+
+ void applyDraftDisplay(VideoScreen screen) {
+ screen.surface = draft.surface == null ? ScreenSurface.FLAT : draft.surface;
+ screen.stereo3d = draft.stereo3d;
+ screen.spherePreset = draft.spherePreset;
+ screen.sphereCenter = draft.sphereCenter == null ? new Vector3f() : new Vector3f(draft.sphereCenter);
+ screen.sphereRadius = draft.sphereRadius;
+ screen.sphereLat = VideoScreen.clampSphereSegments(draft.sphereLat);
+ screen.sphereLon = VideoScreen.clampSphereSegments(draft.sphereLon);
+ screen.sphereRotX = draft.sphereRotX;
+ screen.sphereRotY = draft.sphereRotY;
+ screen.sphereRotZ = draft.sphereRotZ;
+ screen.sphereSkybox = draft.sphereSkybox;
+ screen.ensureValidState();
+ }
+
+ private String uniqueAreaName() {
+ return uniqueName("area", VideoPlayerClient.areas::containsKey);
+ }
+
+ private String uniqueScreenName(String areaName) {
+ ClientVideoArea area = VideoPlayerClient.areas.get(areaName);
+ if (area == null) return "screen1";
+ return uniqueName("screen", name -> area.getScreen(name) != null);
+ }
+
+ private String uniqueName(String prefix, NameExists exists) {
+ for (int i = 1; i < 1000; i++) {
+ String name = prefix + i;
+ if (!exists.test(name)) return name;
+ }
+ return prefix + System.currentTimeMillis();
+ }
+
+ private void setStatus(Component status, boolean error) {
+ this.status = status;
+ this.statusError = error;
+ }
+
+ private void setStatus(String key, String fallback, boolean error, Object... args) {
+ setStatus(VpTexts.tr(key, fallback, args), error);
+ }
+
+ private void setStatusWithInput(String key, String fallback, boolean error, Object... args) {
+ setStatus(Component.translatableWithFallback(key, fallback, args), error);
+ }
+
+ private void setStatusWithOpenKey(String key, String fallback, boolean error, Object... args) {
+ Object[] translatedArgs = Arrays.copyOf(args, args.length + 1);
+ translatedArgs[args.length] = openKeyText();
+ setStatus(Component.translatableWithFallback(key, fallback, translatedArgs), error);
+ }
+
+ Component leftMouseText() {
+ return VpInputTexts.mouseButton(GLFW.GLFW_MOUSE_BUTTON_LEFT);
+ }
+
+ Component rightMouseText() {
+ return VpInputTexts.mouseButton(GLFW.GLFW_MOUSE_BUTTON_RIGHT);
+ }
+
+ private Target target() {
+ if (draft.operation == null) {
+ return draft.target == null ? Target.AREA : draft.target;
+ }
+ return draft.operation.target();
+ }
+
+ @FunctionalInterface
+ private interface NameExists {
+ boolean test(String name);
+ }
+
+ public enum Target {
+ AREA,
+ SCREEN;
+
+ public Target next() {
+ return this == AREA ? SCREEN : AREA;
+ }
+
+ public String label() {
+ return labelText().getString();
+ }
+
+ public Component labelText() {
+ return this == AREA
+ ? VpTexts.tr("label.videoplayer.target.area", "Area")
+ : VpTexts.tr("label.videoplayer.target.screen", "Screen");
+ }
+ }
+
+ public enum Operation {
+ CREATE_AREA,
+ CREATE_SCREEN,
+ EDIT_SCREEN_GEOMETRY;
+
+ public Target target() {
+ return this == CREATE_AREA ? Target.AREA : Target.SCREEN;
+ }
+
+ public String label() {
+ return labelText().getString();
+ }
+
+ public Component labelText() {
+ return switch (this) {
+ case CREATE_AREA -> VpTexts.tr("label.videoplayer.operation.create_area", "Create Area");
+ case CREATE_SCREEN -> VpTexts.tr("label.videoplayer.operation.create_screen", "Create Screen");
+ case EDIT_SCREEN_GEOMETRY -> VpTexts.tr("label.videoplayer.operation.edit_screen", "Edit Screen");
+ };
+ }
+ }
+
+ public enum ScreenMode {
+ RECTANGLE,
+ FREE;
+
+ public ScreenMode next() {
+ return this == RECTANGLE ? FREE : RECTANGLE;
+ }
+
+ public String label() {
+ return labelText().getString();
+ }
+
+ public Component labelText() {
+ return this == RECTANGLE
+ ? VpTexts.tr("label.videoplayer.screen_mode.rectangle", "Two-point Rectangle")
+ : VpTexts.tr("label.videoplayer.screen_mode.free", "Freeform Polygon");
+ }
+ }
+
+ public enum GizmoAxis {
+ X,
+ Y,
+ Z;
+
+ public Vector3f vector() {
+ return switch (this) {
+ case X -> new Vector3f(1, 0, 0);
+ case Y -> new Vector3f(0, 1, 0);
+ case Z -> new Vector3f(0, 0, 1);
+ };
+ }
+
+ public String label() {
+ return switch (this) {
+ case X -> "X";
+ case Y -> "Y";
+ case Z -> "Z";
+ };
+ }
+ }
+
+ public static final class Draft {
+ public Operation operation = Operation.CREATE_AREA;
+ public Target target = Target.AREA;
+ public ScreenMode screenMode = ScreenMode.RECTANGLE;
+ public ScreenSurface surface = ScreenSurface.FLAT;
+ public boolean stereo3d;
+ public boolean spherePreset;
+ public Vector3f sphereCenter;
+ public float sphereRadius = 10;
+ public int sphereLat = 32;
+ public int sphereLon = 32;
+ public float sphereRotX;
+ public float sphereRotY;
+ public float sphereRotZ;
+ public boolean sphereSkybox;
+ public int rectangleRotation;
+ public boolean rectangleFlipHorizontal;
+ public boolean rectangleFlipVertical;
+ public String areaName = "";
+ public String name = "";
+ public String source = "";
+
+ public Draft copy() {
+ Draft copy = new Draft();
+ copy.copyFrom(this);
+ return copy;
+ }
+
+ public void copyFrom(Draft other) {
+ operation = other.operation == null ? Operation.CREATE_AREA : other.operation;
+ target = other.target;
+ screenMode = other.screenMode;
+ surface = other.surface == null ? ScreenSurface.FLAT : other.surface;
+ stereo3d = other.stereo3d;
+ spherePreset = other.spherePreset;
+ sphereCenter = other.sphereCenter == null ? null : new Vector3f(other.sphereCenter);
+ sphereRadius = other.sphereRadius;
+ sphereLat = other.sphereLat;
+ sphereLon = other.sphereLon;
+ sphereRotX = other.sphereRotX;
+ sphereRotY = other.sphereRotY;
+ sphereRotZ = other.sphereRotZ;
+ sphereSkybox = other.sphereSkybox;
+ rectangleRotation = Math.floorMod(other.rectangleRotation, 4);
+ rectangleFlipHorizontal = other.rectangleFlipHorizontal;
+ rectangleFlipVertical = other.rectangleFlipVertical;
+ areaName = normalize(other.areaName);
+ name = normalize(other.name);
+ source = normalize(other.source);
+ target = operation.target();
+ }
+
+ private String normalize(String value) {
+ return value == null ? "" : value.trim();
+ }
+ }
+
+ public static final class SelectionPoint {
+ public final Vector3f point;
+ public final BlockPos blockPos;
+ public final Direction side;
+
+ private SelectionPoint(Vector3f point, BlockPos blockPos, Direction side) {
+ this.point = point;
+ this.blockPos = blockPos;
+ this.side = side;
+ }
+
+ public static SelectionPoint from(BlockHitResult hit, boolean snap) {
+ Vec3 pos = hit.getLocation();
+ Direction side = hit.getDirection();
+ Vector3f point = new Vector3f(
+ (float) pos.x + side.getStepX() * POINT_NORMAL_OFFSET,
+ (float) pos.y + side.getStepY() * POINT_NORMAL_OFFSET,
+ (float) pos.z + side.getStepZ() * POINT_NORMAL_OFFSET
+ );
+ if (snap) snapPoint(point);
+ return new SelectionPoint(
+ point,
+ hit.getBlockPos().immutable(),
+ side
+ );
+ }
+
+ public String format() {
+ return String.format(Locale.ROOT, "%.4f %.4f %.4f", point.x, point.y, point.z);
+ }
+ }
+
+ private record Ray(Vec3 origin, Vec3 direction) {
+ }
+}
diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/VideoCreationScreen.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/VideoCreationScreen.java
new file mode 100644
index 0000000..936063a
--- /dev/null
+++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/VideoCreationScreen.java
@@ -0,0 +1,217 @@
+package com.github.squi2rel.vp.creation;
+
+import com.github.squi2rel.vp.video.VideoScreen;
+import com.github.squi2rel.vp.ClientPacketHandler;
+import com.github.squi2rel.vp.i18n.VpTexts;
+import com.github.squi2rel.vp.network.RequestResultStatus;
+import java.util.List;
+import net.minecraft.ChatFormatting;
+import net.minecraft.client.gui.GuiGraphicsExtractor;
+import net.minecraft.client.gui.components.Button;
+import net.minecraft.client.gui.screens.Screen;
+import net.minecraft.network.chat.Component;
+
+public class VideoCreationScreen extends Screen {
+ private final VideoCreationEditor editor;
+ private final VideoCreationEditor.Draft draft;
+
+ private FilteredEditBox nameField;
+ private FilteredEditBox sourceField;
+ private Button targetButton;
+ private Button screenModeButton;
+ private Button areaButton;
+ private Button sourceButton;
+ private Button selectionButton;
+ private Button confirmButton;
+
+ public VideoCreationScreen(VideoCreationEditor editor) {
+ super(VpTexts.tr("screen.videoplayer.creation", "VideoPlayer Creation"));
+ this.editor = editor;
+ this.draft = editor.draft().copy();
+ }
+
+ @Override
+ protected void init() {
+ int panelWidth = Math.min(320, width - 40);
+ int left = (width - panelWidth) / 2;
+ int top = Math.max(24, height / 2 - 112);
+ int row = top + 24;
+
+ nameField = new FilteredEditBox(font, left + 88, row, panelWidth - 88, 20, VpTexts.tr("label.videoplayer.name", "Name"));
+ nameField.setMaxLength(VideoScreen.MAX_NAME_BYTES);
+ nameField.setFilter(VideoScreen::validNameInput);
+ nameField.setValue(draft.name);
+ addRenderableWidget(nameField);
+
+ row += 28;
+ targetButton = addRenderableWidget(Button.builder(Component.empty(), button -> {
+ draft.target = draft.target.next();
+ if (draft.target == VideoCreationEditor.Target.SCREEN && draft.areaName.isEmpty()) {
+ draft.areaName = editor.areaNames().stream().findFirst().orElse("");
+ }
+ draft.name = suggestedName();
+ nameField.setValue(draft.name);
+ syncButtons();
+ }).bounds(left + 88, row, panelWidth - 88, 20).build());
+
+ row += 28;
+ areaButton = addRenderableWidget(Button.builder(Component.empty(), button -> {
+ List names = editor.areaNames();
+ if (names.isEmpty()) return;
+ int index = names.indexOf(draft.areaName);
+ draft.areaName = names.get((index + 1 + names.size()) % names.size());
+ draft.source = "";
+ syncButtons();
+ }).bounds(left + 88, row, panelWidth - 88, 20).build());
+
+ row += 28;
+ screenModeButton = addRenderableWidget(Button.builder(Component.empty(), button -> {
+ draft.screenMode = draft.screenMode.next();
+ syncButtons();
+ }).bounds(left + 88, row, panelWidth - 88, 20).build());
+
+ row += 28;
+ sourceField = new FilteredEditBox(font, left + 88, row, panelWidth - 168, 20, VpTexts.tr("label.videoplayer.source", "Source"));
+ sourceField.setMaxLength(VideoScreen.MAX_NAME_BYTES);
+ sourceField.setFilter(VideoScreen::validNameInput);
+ sourceField.setValue(draft.source);
+ addRenderableWidget(sourceField);
+ sourceButton = addRenderableWidget(Button.builder(VpTexts.tr("button.videoplayer.select", "Select"), button -> {
+ List names = editor.realScreenNames(draft.areaName);
+ if (names.isEmpty()) {
+ draft.source = "";
+ } else {
+ String current = sourceField.getValue().trim();
+ int index = names.indexOf(current);
+ draft.source = names.get((index + 1 + names.size()) % names.size());
+ }
+ sourceField.setValue(draft.source);
+ syncButtons();
+ }).bounds(left + panelWidth - 72, row, 72, 20).build());
+
+ row += 34;
+ selectionButton = addRenderableWidget(Button.builder(VpTexts.tr("button.videoplayer.start_selection", "Start Selection"), button -> {
+ copyFieldsToDraft();
+ editor.beginSelection(draft);
+ }).bounds(left, row, 96, 20).build());
+ addRenderableWidget(Button.builder(VpTexts.tr("button.videoplayer.clear_selection", "Clear Selection"), button -> {
+ editor.clearSelection();
+ syncButtons();
+ }).bounds(left + 104, row, 96, 20).build());
+ confirmButton = addRenderableWidget(Button.builder(VpTexts.tr("button.videoplayer.create", "Create"), button -> {
+ copyFieldsToDraft();
+ editor.confirm(result -> {
+ if (ClientPacketHandler.denied(result)) {
+ button.setMessage(VpTexts.tr("error.videoplayer.permission_denied", "Permission denied"));
+ return;
+ }
+ if (result != null && result.status() == RequestResultStatus.OK) onClose();
+ });
+ syncButtons();
+ }).bounds(left + panelWidth - 96, row, 96, 20).build());
+
+ row += 28;
+ addRenderableWidget(Button.builder(VpTexts.tr("button.videoplayer.close", "Close"), button -> onClose()).bounds(left, row, panelWidth, 20).build());
+
+ syncButtons();
+ setInitialFocus(nameField);
+ }
+
+ @Override
+ public void tick() {
+ copyFieldsToDraft();
+ syncButtons();
+ }
+
+ @Override
+ public void onClose() {
+ minecraft.gui.setScreen(null);
+ }
+
+ @Override
+ public boolean isPauseScreen() {
+ return false;
+ }
+
+ @Override
+ public void extractRenderState(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
+ extractBackground(context, mouseX, mouseY, delta);
+
+ int panelWidth = Math.min(320, width - 40);
+ int left = (width - panelWidth) / 2;
+ int top = Math.max(24, height / 2 - 112);
+ int bottom = top + 228;
+
+ context.fill(left - 12, top - 12, left + panelWidth + 12, bottom, 0xCC101010);
+ context.centeredText(font, title, width / 2, top - 2, 0xFFFFFFFF);
+ context.text(font, VpTexts.tr("label.videoplayer.name", "Name"), left, top + 28, 0xFFE0E0E0);
+ context.text(font, VpTexts.tr("label.videoplayer.type", "Type"), left, top + 56, 0xFFE0E0E0);
+ context.text(font, VpTexts.tr("label.videoplayer.area", "Area"), left, top + 84, 0xFFE0E0E0);
+ context.text(font, VpTexts.tr("label.videoplayer.mode", "Mode"), left, top + 112, 0xFFE0E0E0);
+ context.text(font, VpTexts.tr("label.videoplayer.source", "Source"), left, top + 140, 0xFFE0E0E0);
+
+ String points = editor.pointProgress();
+ int statusColor = editor.statusError() ? 0xFFFF5555 : 0xFF55FF55;
+ context.text(font, VpTexts.tr("label.videoplayer.selection_points", "Selection: %s", points), left, top + 172, 0xFFE0E0E0);
+ context.text(font, editor.status(), left + 72, top + 172, statusColor);
+
+ if (draft.target == VideoCreationEditor.Target.SCREEN && draft.areaName.isEmpty()) {
+ context.text(font, VpTexts.tr("error.videoplayer.need_area_first", "Enter or create an Area first").withStyle(ChatFormatting.RED), left, top + 190, 0xFFFF5555);
+ } else if (draft.target == VideoCreationEditor.Target.SCREEN) {
+ context.text(font, Component.translatableWithFallback(
+ "hint.videoplayer.select_points",
+ "%1$s points, %2$s undo, press %3$s to return and confirm",
+ editor.leftMouseText(), editor.rightMouseText(), editor.openKeyText()
+ ), left, top + 190, 0xFFB0B0B0);
+ } else {
+ context.text(font, VpTexts.tr("hint.videoplayer.area_two_blocks", "Area uses two blocks to create a bounding box"), left, top + 190, 0xFFB0B0B0);
+ }
+
+ super.extractRenderState(context, mouseX, mouseY, delta);
+ }
+
+ private void copyFieldsToDraft() {
+ draft.name = nameField == null ? draft.name : nameField.getValue().trim();
+ draft.source = sourceField == null ? draft.source : sourceField.getValue().trim();
+ editor.draft().copyFrom(draft);
+ }
+
+ private void syncButtons() {
+ if (nameField != null && !nameField.getValue().equals(draft.name)) {
+ draft.name = nameField.getValue().trim();
+ }
+ if (sourceField != null && !sourceField.getValue().equals(draft.source)) {
+ draft.source = sourceField.getValue().trim();
+ }
+ editor.draft().copyFrom(draft);
+ boolean screen = draft.target == VideoCreationEditor.Target.SCREEN;
+ targetButton.setMessage(VpTexts.tr("label.videoplayer.type_value", "Type: %s", draft.target.label()));
+ areaButton.setMessage(draft.areaName.isEmpty()
+ ? VpTexts.tr("label.videoplayer.area_none", "Area: None")
+ : VpTexts.tr("label.videoplayer.area_value", "Area: %s", draft.areaName));
+ areaButton.active = screen && !editor.areaNames().isEmpty();
+ screenModeButton.setMessage(VpTexts.tr("label.videoplayer.mode_value", "Mode: %s", draft.screenMode.label()));
+ screenModeButton.active = screen;
+ sourceField.visible = screen;
+ sourceField.active = screen;
+ sourceButton.visible = screen;
+ sourceButton.active = screen;
+ selectionButton.active = canSelect();
+ confirmButton.active = editor.ready() && canSubmit();
+ }
+
+ private boolean canSelect() {
+ return draft.target == VideoCreationEditor.Target.AREA || editor.areaNames().contains(draft.areaName);
+ }
+
+ private boolean canSubmit() {
+ return canSelect();
+ }
+
+ private String suggestedName() {
+ if (draft.target == VideoCreationEditor.Target.AREA) {
+ return editor.suggestedAreaName();
+ }
+ return editor.suggestedScreenName(draft.areaName);
+ }
+}
diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/VideoManagementScreen.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/VideoManagementScreen.java
new file mode 100644
index 0000000..d88d47b
--- /dev/null
+++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/VideoManagementScreen.java
@@ -0,0 +1,3314 @@
+package com.github.squi2rel.vp.creation;
+
+import com.github.squi2rel.vp.ClientPacketHandler;
+import com.github.squi2rel.vp.ClientPermissionCache;
+import com.github.squi2rel.vp.ScreenRenderer;
+import com.github.squi2rel.vp.VideoConnectionDiagnostics;
+import com.github.squi2rel.vp.VideoPlayerClient;
+import com.github.squi2rel.vp.danmaku.ClientDanmakuController;
+import com.github.squi2rel.vp.danmaku.ClientDanmakuRenderer;
+import com.github.squi2rel.vp.danmaku.ClientSubtitleController;
+import com.github.squi2rel.vp.i18n.VpTexts;
+import com.github.squi2rel.vp.network.RequestResultStatus;
+import com.github.squi2rel.vp.permission.VideoPermissionAction;
+import com.github.squi2rel.vp.provider.VideoInfo;
+import com.github.squi2rel.vp.provider.YouTubeProvider;
+import com.github.squi2rel.vp.provider.bilibili.BiliBiliVideoProvider;
+import com.github.squi2rel.vp.provider.bilibili.BiliQuality;
+import com.github.squi2rel.vp.provider.youtube.YouTubeQuality;
+import com.github.squi2rel.vp.video.ClientVideoArea;
+import com.github.squi2rel.vp.video.ClientVideoScreen;
+import com.github.squi2rel.vp.video.AudioLevelSnapshot;
+import com.github.squi2rel.vp.video.MetaType;
+import com.github.squi2rel.vp.video.MetaValue;
+import com.github.squi2rel.vp.video.MpvVideoBackend;
+import com.github.squi2rel.vp.video.PlaybackDiagnostics;
+import com.github.squi2rel.vp.video.PlaybackFailureReason;
+import com.github.squi2rel.vp.video.ScreenMetadata;
+import com.github.squi2rel.vp.video.ScreenSurface;
+import com.github.squi2rel.vp.video.ScreenVolumeCache;
+import com.github.squi2rel.vp.video.VideoBackends;
+import com.github.squi2rel.vp.video.VideoPlayer;
+import com.github.squi2rel.vp.video.VideoScreen;
+import org.joml.Vector3f;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.BooleanSupplier;
+import java.util.function.Consumer;
+import java.util.function.IntConsumer;
+import java.util.function.IntFunction;
+import java.util.function.Predicate;
+import net.minecraft.ChatFormatting;
+import net.minecraft.client.gui.GuiGraphicsExtractor;
+import net.minecraft.client.gui.components.AbstractWidget;
+import net.minecraft.client.gui.components.EditBox;
+import net.minecraft.client.gui.components.Renderable;
+import net.minecraft.client.gui.screens.Screen;
+import net.minecraft.client.input.MouseButtonEvent;
+import net.minecraft.network.chat.Component;
+
+public class VideoManagementScreen extends Screen implements ServerStateScreen {
+ private static final int SIDEBAR_WIDTH = 96;
+ private static final int ROW_HEIGHT = 20;
+ private static final int GAP = 8;
+ private static final int CONTROL_HEIGHT = 18;
+ private static final int BUTTON_ROW_GAP = 24;
+ private static final int FORM_ROW_GAP = 32;
+ private static final int PARAM_ROW_GAP = 30;
+ private static final int LABEL_OFFSET = 11;
+ private static final int SCREEN_SETTINGS_CONNECTION_ADDRESS_Y = 16;
+ private static final int SCREEN_SETTINGS_CONNECTION_STATUS_Y = 29;
+ private static final int SCREEN_SETTINGS_DISPLAY_LABEL_Y = 46;
+ private static final int SCREEN_SETTINGS_DISPLAY_Y = SCREEN_SETTINGS_DISPLAY_LABEL_Y + 18;
+ private static final int SCREEN_SETTINGS_META_Y = SCREEN_SETTINGS_DISPLAY_Y + FORM_ROW_GAP * 6 + CONTROL_HEIGHT + 10;
+ private static final int SCREEN_SETTINGS_META_CONTENT_Y = SCREEN_SETTINGS_META_Y + 18;
+ private static final int PLAYBACK_PROGRESS_HEIGHT = 18;
+ private static final int PLAYBACK_PROGRESS_GAP = 8;
+ private static final int PLAYBACK_BOTTOM_CONTROLS_GAP = 6;
+ private static final long PLAYBACK_SEEK_THROTTLE_MS = 250L;
+ private static final long PLAYBACK_PREVIEW_END_GUARD_MS = 1000L;
+ private static final long DIAGNOSTICS_REFRESH_INTERVAL_MS = 2_000L;
+ private static final float MIN_SCREEN_SCALE = 0.0625f;
+ private static final float MAX_SCREEN_SCALE = 16f;
+ private static final int DANMAKU_OVERLAY_WIDTH = 268;
+ private static final int DANMAKU_OVERLAY_BASE_HEIGHT = 198;
+ private static final int DANMAKU_OVERLAY_DENSITY_EXTRA_HEIGHT = 33;
+ private static final int BILI_QUALITY_OVERLAY_MIN_WIDTH = 96;
+ private static final int BILI_QUALITY_OVERLAY_HEADER_HEIGHT = 28;
+ private static final int BILI_QUALITY_OVERLAY_PADDING = 10;
+ private static final int BILI_QUALITY_OVERLAY_BUTTON_GAP = 5;
+ private static final int BILI_QUALITY_OVERLAY_VISIBLE_ROWS = 5;
+ private static final int[] DANMAKU_RANGE_OPTIONS = {25, 50, 75, 100};
+ private static final String[] DANMAKU_SPEED_KEYS = {
+ "label.videoplayer.danmaku_speed.slowest",
+ "label.videoplayer.danmaku_speed.slow",
+ "label.videoplayer.danmaku_speed.medium",
+ "label.videoplayer.danmaku_speed.fast",
+ "label.videoplayer.danmaku_speed.fastest"
+ };
+ private static final String[] DANMAKU_SPEED_FALLBACKS = {"Slowest", "Slow", "Medium", "Fast", "Fastest"};
+ private static final String[] DANMAKU_DENSITY_KEYS = {
+ "label.videoplayer.danmaku_density.normal",
+ "label.videoplayer.danmaku_density.more",
+ "label.videoplayer.danmaku_density.overlap"
+ };
+ private static final String[] DANMAKU_DENSITY_FALLBACKS = {"Normal", "More", "Overlap"};
+ private static final VpUiTheme THEME = VpUiTheme.classic();
+ private final VideoCreationEditor editor;
+ private Tab tab;
+ private String selectedAreaName;
+ private String selectedScreenName;
+ private int areaScroll;
+ private int screenScroll;
+ private int contentScroll;
+ private boolean confirmDeleteArea;
+ private boolean confirmDeleteScreen;
+ private WidgetGroup widgetGroup = WidgetGroup.FIXED;
+ private final List fixedDrawables = new ArrayList<>();
+ private final List areaScrollDrawables = new ArrayList<>();
+ private final List screenScrollDrawables = new ArrayList<>();
+ private final List contentScrollDrawables = new ArrayList<>();
+ private final List danmakuOverlayDrawables = new ArrayList<>();
+ private final List danmakuOverlayWidgets = new ArrayList<>();
+ private int areaScrollContentHeight;
+ private int screenScrollContentHeight;
+ private int contentScrollContentHeight;
+
+ private EditBox nameField;
+ private EditBox sourceField;
+ private EditBox urlField;
+ private EditBox customKeyField;
+ private EditBox customValueField;
+ private EditBox sphereCenterXField;
+ private EditBox sphereCenterYField;
+ private EditBox sphereCenterZField;
+ private EditBox sphereRadiusField;
+ private EditBox sphereLatField;
+ private EditBox sphereLonField;
+ private EditBox sphereRotXField;
+ private EditBox sphereRotYField;
+ private EditBox sphereRotZField;
+ private VpProgressSliderWidget playbackProgressSlider;
+ private boolean playbackProgressPreview;
+ private boolean playbackPreviewPinned;
+ private boolean danmakuOverlayOpen;
+ private boolean biliLocalQualityOverlayOpen;
+ private boolean biliScreenQualityOverlayOpen;
+ private boolean youtubeScreenQualityOverlay;
+ private boolean ccSubtitleOverlayOpen;
+ private int danmakuOverlayX;
+ private int danmakuOverlayY;
+ private int danmakuOverlayW;
+ private int danmakuOverlayH;
+ private int biliQualityOverlayScroll;
+ private int biliQualityOverlayViewportTop;
+ private int biliQualityOverlayViewportBottom;
+ private int biliQualityOverlayContentHeight;
+ private AbstractWidget activeDanmakuOverlayWidget;
+ private ClientVideoScreen playbackProgressDragScreen;
+ private boolean playbackProgressPausedBeforeDrag;
+ private boolean playbackProgressPauseApplied;
+ private long lastPlaybackPreviewSeekTime;
+ private MetaType customMetaType = MetaType.INT;
+ private String areaSignature = "";
+ private String screenSignature = "";
+ private String metadataSignature = "";
+ private String ccSubtitleOverlaySignature = "";
+ private long lastDiagnosticsRequestAt;
+ private boolean diagnosticsRequestInFlight;
+ private VpButtonWidget diagnosticsRefreshButton;
+ private VpButtonWidget diagnosticsMuteButton;
+ private final DiagnosticsReviewSession diagnosticsReview;
+
+ public VideoManagementScreen(VideoCreationEditor editor, ClientVideoScreen focusedScreen) {
+ this(editor, focusedScreen, focusedScreen == null ? Tab.CREATE_EDIT : Tab.PLAYBACK);
+ }
+
+ public static VideoManagementScreen diagnostics(VideoCreationEditor editor, ClientVideoScreen focusedScreen) {
+ return new VideoManagementScreen(editor, focusedScreen, Tab.DIAGNOSTICS,
+ false, false, false, false, false, false, new DiagnosticsReviewSession());
+ }
+
+ private VideoManagementScreen(VideoCreationEditor editor, ClientVideoScreen focusedScreen, Tab tab) {
+ this(editor, focusedScreen, tab, false);
+ }
+
+ private VideoManagementScreen(VideoCreationEditor editor, ClientVideoScreen focusedScreen, Tab tab, boolean danmakuOverlayOpen) {
+ this(editor, focusedScreen, tab, danmakuOverlayOpen, false, false, false, false, false, null);
+ }
+
+ private VideoManagementScreen(VideoCreationEditor editor, ClientVideoScreen focusedScreen, Tab tab,
+ boolean danmakuOverlayOpen, boolean biliLocalQualityOverlayOpen,
+ boolean biliScreenQualityOverlayOpen, boolean youtubeScreenQualityOverlay,
+ boolean ccSubtitleOverlayOpen,
+ boolean playbackPreviewPinned, DiagnosticsReviewSession diagnosticsReview) {
+ super(VpTexts.tr("screen.videoplayer.management", "VideoPlayer Management"));
+ this.editor = editor;
+ this.tab = tab;
+ this.diagnosticsReview = diagnosticsReview;
+ this.playbackPreviewPinned = playbackPreviewPinned && tab == Tab.PLAYBACK;
+ this.danmakuOverlayOpen = danmakuOverlayOpen && tab == Tab.PLAYBACK;
+ this.biliLocalQualityOverlayOpen = biliLocalQualityOverlayOpen && tab == Tab.PLAYBACK;
+ this.biliScreenQualityOverlayOpen = biliScreenQualityOverlayOpen && tab == Tab.SCREEN_SETTINGS;
+ this.youtubeScreenQualityOverlay = youtubeScreenQualityOverlay && this.biliScreenQualityOverlayOpen;
+ this.ccSubtitleOverlayOpen = ccSubtitleOverlayOpen && tab == Tab.PLAYBACK;
+ VideoCreationEditor.Draft draft = editor.draft();
+ if (focusedScreen != null) {
+ selectedAreaName = focusedScreen.area.name;
+ selectedScreenName = focusedScreen.name;
+ draft.operation = VideoCreationEditor.Operation.EDIT_SCREEN_GEOMETRY;
+ draft.areaName = selectedAreaName;
+ draft.name = selectedScreenName;
+ draft.source = focusedScreen.source == null ? "" : focusedScreen.source;
+ } else {
+ selectedAreaName = draft.areaName == null || draft.areaName.isBlank() ? firstAreaName() : draft.areaName;
+ selectedScreenName = draft.name == null || draft.name.isBlank() ? firstScreenName(selectedAreaName) : draft.name;
+ }
+ ensureSelection();
+ syncDraftFromSelection(false, focusedScreen != null || !preserveDraftForCurrentSelection());
+ }
+
+ private VideoManagementScreen(VideoCreationEditor editor, Tab tab, String selectedAreaName, String selectedScreenName,
+ int areaScroll, int screenScroll, int contentScroll, boolean confirmDeleteArea, boolean confirmDeleteScreen,
+ MetaType customMetaType, boolean preserveDraftDisplay, boolean danmakuOverlayOpen) {
+ this(editor, tab, selectedAreaName, selectedScreenName, areaScroll, screenScroll, contentScroll, confirmDeleteArea, confirmDeleteScreen,
+ customMetaType, preserveDraftDisplay, danmakuOverlayOpen, false, false, false, false, false, null);
+ }
+
+ private VideoManagementScreen(VideoCreationEditor editor, Tab tab, String selectedAreaName, String selectedScreenName,
+ int areaScroll, int screenScroll, int contentScroll, boolean confirmDeleteArea, boolean confirmDeleteScreen,
+ MetaType customMetaType, boolean preserveDraftDisplay,
+ boolean danmakuOverlayOpen, boolean biliLocalQualityOverlayOpen,
+ boolean biliScreenQualityOverlayOpen, boolean youtubeScreenQualityOverlay,
+ boolean ccSubtitleOverlayOpen,
+ boolean playbackPreviewPinned, DiagnosticsReviewSession diagnosticsReview) {
+ super(VpTexts.tr("screen.videoplayer.management", "VideoPlayer Management"));
+ this.editor = editor;
+ this.tab = tab;
+ this.diagnosticsReview = diagnosticsReview;
+ this.playbackPreviewPinned = playbackPreviewPinned && tab == Tab.PLAYBACK;
+ this.danmakuOverlayOpen = danmakuOverlayOpen && tab == Tab.PLAYBACK;
+ this.biliLocalQualityOverlayOpen = biliLocalQualityOverlayOpen && tab == Tab.PLAYBACK;
+ this.biliScreenQualityOverlayOpen = biliScreenQualityOverlayOpen && tab == Tab.SCREEN_SETTINGS;
+ this.youtubeScreenQualityOverlay = youtubeScreenQualityOverlay && this.biliScreenQualityOverlayOpen;
+ this.ccSubtitleOverlayOpen = ccSubtitleOverlayOpen && tab == Tab.PLAYBACK;
+ this.selectedAreaName = selectedAreaName;
+ this.selectedScreenName = selectedScreenName;
+ this.areaScroll = Math.max(0, areaScroll);
+ this.screenScroll = Math.max(0, screenScroll);
+ this.contentScroll = Math.max(0, contentScroll);
+ this.confirmDeleteArea = confirmDeleteArea;
+ this.confirmDeleteScreen = confirmDeleteScreen;
+ if (customMetaType != null) this.customMetaType = customMetaType;
+ ensureSelection();
+ syncDraftFromSelection(false, !preserveDraftDisplay && !preserveDraftForCurrentSelection());
+ }
+
+ @Override
+ protected void init() {
+ ensureSelection();
+ resetUiGroups();
+ int margin = 14;
+ int sidebarX = margin;
+ int mainX = sidebarX + SIDEBAR_WIDTH + 14;
+ int mainW = Math.max(220, width - mainX - margin);
+ int top = 24;
+ areaScroll = clampScroll(areaScroll, areaNames().size() * ROW_HEIGHT, sidebarAreaViewportHeight());
+ screenScroll = clampScroll(screenScroll, screensForSelectedArea().size() * ROW_HEIGHT, sidebarScreenViewportHeight());
+ contentScroll = clampScroll(contentScroll, estimateContentHeight(mainW), contentViewportHeight());
+
+ addTabs(mainX, top, mainW);
+ int contentTop = contentTop(mainW);
+ addSidebar(sidebarX);
+
+ widgetGroup = WidgetGroup.CONTENT_SCROLL;
+ switch (tab) {
+ case CREATE_EDIT -> initCreateEdit(mainX, contentTop - contentScroll, mainW);
+ case PLAYBACK -> initPlayback(mainX, contentTop - contentScroll, mainW);
+ case SCREEN_SETTINGS -> initScreenSettings(mainX, contentTop - contentScroll, mainW);
+ case DIAGNOSTICS -> initDiagnostics(mainX, contentTop - contentScroll, mainW);
+ }
+ widgetGroup = WidgetGroup.FIXED;
+ initReconnectServerButton(mainX, mainW);
+ areaSignature = areaSignature();
+ screenSignature = screenSignature();
+ metadataSignature = metadataSignature();
+ ccSubtitleOverlaySignature = ccSubtitleOverlaySignature();
+ updateDiagnosticsReview();
+ }
+
+ @Override
+ public boolean isPauseScreen() {
+ return false;
+ }
+
+ @Override
+ public void tick() {
+ super.tick();
+ updateDiagnosticsReview();
+ String currentAreaSignature = areaSignature();
+ String currentScreenSignature = screenSignature();
+ String currentMetadataSignature = metadataSignature();
+ String currentCcSubtitleOverlaySignature = ccSubtitleOverlaySignature();
+ if (!currentAreaSignature.equals(areaSignature)
+ || !currentScreenSignature.equals(screenSignature)
+ || !currentMetadataSignature.equals(metadataSignature)
+ || ccSubtitleOverlayOpen && !currentCcSubtitleOverlaySignature.equals(ccSubtitleOverlaySignature)) {
+ reopen(null);
+ }
+ }
+
+ @Override
+ public void onClose() {
+ endPlaybackProgressDrag();
+ if (diagnosticsReview != null) diagnosticsReview.close();
+ minecraft.gui.setScreen(null);
+ }
+
+ @Override
+ public void removed() {
+ if (diagnosticsReview != null && !diagnosticsReview.consumeHandoff()) diagnosticsReview.close();
+ super.removed();
+ }
+
+ @Override
+ public void extractBackground(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
+ context.fill(0, 0, width, height, VpUiRenderer.withAlpha(THEME.canvasBackgroundColor(), 0xCC));
+ }
+
+ @Override
+ public void extractRenderState(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
+ extractBackground(context, mouseX, mouseY, delta);
+ int margin = 14;
+ int sidebarX = margin;
+ int mainX = sidebarX + SIDEBAR_WIDTH + 14;
+ int mainW = Math.max(220, width - mainX - margin);
+ int top = 24;
+ int panelBottom = height - 18;
+ int contentTop = contentTop(mainW);
+
+ if (tab == Tab.DIAGNOSTICS) requestDiagnosticsIfDue();
+
+ VpUiRenderer.drawBox(context, sidebarX - 8, 16, SIDEBAR_WIDTH + 16, panelBottom - 16, THEME.panelBackgroundColor(), THEME.panelBorderColor());
+ VpUiRenderer.drawBox(context, mainX - 8, 16, mainW + 16, panelBottom - 16, THEME.panelBackgroundColor(), THEME.panelBorderColor());
+ context.text(font, title, sidebarX, 20, THEME.primaryTextColor(), false);
+ drawSidebarLabels(context, sidebarX);
+
+ renderClippedDrawables(context, areaScrollDrawables, mouseX, mouseY, delta,
+ sidebarX, sidebarAreaViewportTop(), sidebarX + SIDEBAR_WIDTH, sidebarAreaViewportBottom());
+ renderClippedDrawables(context, screenScrollDrawables, mouseX, mouseY, delta,
+ sidebarX, sidebarScreenViewportTop(), sidebarX + SIDEBAR_WIDTH, sidebarScreenViewportBottom());
+ renderContent(context, mouseX, mouseY, delta, mainX, contentTop - contentScroll, mainW);
+
+ renderDrawables(context, fixedDrawables, mouseX, mouseY, delta);
+ drawScrollbar(context, sidebarX + SIDEBAR_WIDTH - 4, sidebarAreaViewportTop(), sidebarAreaViewportBottom(), areaScroll, areaScrollContentHeight);
+ drawScrollbar(context, sidebarX + SIDEBAR_WIDTH - 4, sidebarScreenViewportTop(), sidebarScreenViewportBottom(), screenScroll, screenScrollContentHeight);
+ if (!hidePlaybackScrollpane(mouseX, mouseY)) {
+ drawScrollbar(context, mainX + mainW - 4, contentViewportTop(), contentViewportBottom(), contentScroll, contentScrollContentHeight);
+ }
+ renderActiveOverlay(context, mouseX, mouseY, delta);
+ }
+
+ @Override
+ public boolean mouseClicked(MouseButtonEvent click, boolean doubleClick) {
+ if (overlayOpen() && !insideActiveOverlay(click.x(), click.y())) {
+ closeOverlays();
+ activeDanmakuOverlayWidget = null;
+ rebuildWidgets();
+ return true;
+ }
+ if (insideActiveOverlay(click.x(), click.y())) {
+ for (int i = danmakuOverlayWidgets.size() - 1; i >= 0; i--) {
+ AbstractWidget widget = danmakuOverlayWidgets.get(i);
+ if (widget.mouseClicked(click, doubleClick)) {
+ activeDanmakuOverlayWidget = widget;
+ setFocused(widget);
+ if (click.button() == 0) setDragging(true);
+ return true;
+ }
+ }
+ return true;
+ }
+ return super.mouseClicked(click, doubleClick);
+ }
+
+ @Override
+ public boolean mouseDragged(MouseButtonEvent click, double deltaX, double deltaY) {
+ if (activeDanmakuOverlayWidget != null) {
+ return activeDanmakuOverlayWidget.mouseDragged(click, deltaX, deltaY) || insideActiveOverlay(click.x(), click.y());
+ }
+ return super.mouseDragged(click, deltaX, deltaY);
+ }
+
+ @Override
+ public boolean mouseReleased(MouseButtonEvent click) {
+ if (activeDanmakuOverlayWidget != null) {
+ boolean handled = activeDanmakuOverlayWidget.mouseReleased(click);
+ activeDanmakuOverlayWidget = null;
+ setDragging(false);
+ return handled || insideActiveOverlay(click.x(), click.y());
+ }
+ if (insideActiveOverlay(click.x(), click.y())) {
+ return true;
+ }
+ return super.mouseReleased(click);
+ }
+
+ @Override
+ public boolean mouseScrolled(double mouseX, double mouseY, double horizontalAmount, double verticalAmount) {
+ int delta = (int) Math.round(-verticalAmount * 24.0);
+ if (insideActiveOverlay(mouseX, mouseY)) {
+ if (delta != 0 && scrollableOverlayOpen()) {
+ return scrollBiliQualityOverlay(delta);
+ }
+ return true;
+ }
+ if (delta == 0) {
+ return super.mouseScrolled(mouseX, mouseY, horizontalAmount, verticalAmount);
+ }
+
+ int sidebarX = sidebarX();
+ if (inside(mouseX, mouseY, sidebarX, sidebarAreaViewportTop(), sidebarX + SIDEBAR_WIDTH, sidebarAreaViewportBottom())) {
+ return scrollArea(delta);
+ }
+ if (inside(mouseX, mouseY, sidebarX, sidebarScreenViewportTop(), sidebarX + SIDEBAR_WIDTH, sidebarScreenViewportBottom())) {
+ return scrollScreen(delta);
+ }
+
+ int mainX = mainX();
+ int mainW = mainW();
+ if (inside(mouseX, mouseY, mainX, contentViewportTop(), mainX + mainW, contentViewportBottom())) {
+ if (hidePlaybackScrollpane(mouseX, mouseY)) {
+ return true;
+ }
+ return scrollContent(delta);
+ }
+ return super.mouseScrolled(mouseX, mouseY, horizontalAmount, verticalAmount);
+ }
+
+ private boolean scrollArea(int delta) {
+ int next = clampScroll(areaScroll + delta, areaScrollContentHeight, sidebarAreaViewportHeight());
+ if (next == areaScroll) {
+ return false;
+ }
+ preserveCurrentFieldsForReopen();
+ areaScroll = next;
+ reopenPreservingDraft();
+ return true;
+ }
+
+ private boolean scrollScreen(int delta) {
+ int next = clampScroll(screenScroll + delta, screenScrollContentHeight, sidebarScreenViewportHeight());
+ if (next == screenScroll) {
+ return false;
+ }
+ preserveCurrentFieldsForReopen();
+ screenScroll = next;
+ reopenPreservingDraft();
+ return true;
+ }
+
+ private boolean scrollContent(int delta) {
+ int contentHeight = Math.max(contentScrollContentHeight, estimateContentHeight(mainW()));
+ int next = clampScroll(contentScroll + delta, contentHeight, contentViewportHeight());
+ if (next == contentScroll) {
+ return false;
+ }
+ preserveCurrentFieldsForReopen();
+ contentScroll = next;
+ reopenPreservingDraft();
+ return true;
+ }
+
+ private int clampScroll(int scroll, int contentHeight, int viewportHeight) {
+ int maxScroll = Math.max(0, contentHeight - Math.max(1, viewportHeight));
+ return Math.clamp(scroll, 0, maxScroll);
+ }
+
+ private boolean inside(double mouseX, double mouseY, int left, int top, int right, int bottom) {
+ return mouseX >= left && mouseY >= top && mouseX < right && mouseY < bottom;
+ }
+
+ private void resetUiGroups() {
+ widgetGroup = WidgetGroup.FIXED;
+ fixedDrawables.clear();
+ areaScrollDrawables.clear();
+ screenScrollDrawables.clear();
+ contentScrollDrawables.clear();
+ danmakuOverlayDrawables.clear();
+ danmakuOverlayWidgets.clear();
+ activeDanmakuOverlayWidget = null;
+ danmakuOverlayX = 0;
+ danmakuOverlayY = 0;
+ danmakuOverlayW = 0;
+ danmakuOverlayH = 0;
+ biliQualityOverlayScroll = 0;
+ biliQualityOverlayViewportTop = 0;
+ biliQualityOverlayViewportBottom = 0;
+ biliQualityOverlayContentHeight = 0;
+ areaScrollContentHeight = 0;
+ screenScrollContentHeight = 0;
+ contentScrollContentHeight = 0;
+ endPlaybackProgressDrag();
+ playbackProgressSlider = null;
+ }
+
+ private int sidebarX() {
+ return 14;
+ }
+
+ private int mainX() {
+ return sidebarX() + SIDEBAR_WIDTH + 14;
+ }
+
+ private int mainW() {
+ return Math.max(220, width - mainX() - 14);
+ }
+
+ private int topY() {
+ return 24;
+ }
+
+ private int contentTop(int mainW) {
+ return topY() + 36;
+ }
+
+ private int contentViewportTop() {
+ return contentViewportTop(mainW());
+ }
+
+ private int contentViewportTop(int mainW) {
+ return topY() + CONTROL_HEIGHT + 6;
+ }
+
+ private int contentViewportBottom() {
+ int bottom = reconnectServerButtonY() - GAP;
+ if (tab == Tab.PLAYBACK) {
+ bottom = Math.min(bottom, playbackProgressY() - PLAYBACK_PROGRESS_GAP);
+ }
+ return Math.max(contentViewportTop() + 1, bottom);
+ }
+
+ private int contentViewportHeight() {
+ return Math.max(1, contentViewportBottom() - contentViewportTop());
+ }
+
+ private int reconnectServerButtonY() {
+ return Math.max(contentViewportTop() + GAP, height - CONTROL_HEIGHT - 24);
+ }
+
+ private int playbackProgressY() {
+ int controlsY = reconnectServerButtonY() - CONTROL_HEIGHT - PLAYBACK_BOTTOM_CONTROLS_GAP;
+ int progressY = controlsY - PLAYBACK_PROGRESS_GAP - PLAYBACK_PROGRESS_HEIGHT;
+ return Math.max(contentViewportTop() + PLAYBACK_PROGRESS_HEIGHT + PLAYBACK_PROGRESS_GAP, progressY);
+ }
+
+ private int playbackBottomControlsY() {
+ return playbackProgressY() + PLAYBACK_PROGRESS_HEIGHT + PLAYBACK_BOTTOM_CONTROLS_GAP;
+ }
+
+ private boolean hidePlaybackScrollpane(double mouseX, double mouseY) {
+ return showPlaybackProgressPreview(mouseX, mouseY);
+ }
+
+ private boolean showPlaybackProgressPreview(double mouseX, double mouseY) {
+ return tab == Tab.PLAYBACK
+ && (playbackPreviewPinned || playbackProgressPreview || overlayOpen() || insidePlaybackProgressPreviewZone(mouseX, mouseY));
+ }
+
+ private boolean insidePlaybackProgressPreviewZone(double mouseX, double mouseY) {
+ if (playbackProgressSlider == null) return false;
+ int left = mainX();
+ int right = left + mainW();
+ int top = playbackProgressY();
+ int bottom = playbackBottomControlsY() + CONTROL_HEIGHT;
+ return inside(mouseX, mouseY, left, top, right, bottom);
+ }
+
+ private int sidebarAreaLabelY() {
+ return topY() + 8;
+ }
+
+ private int sidebarAreaViewportTop() {
+ return sidebarAreaLabelY() + 16;
+ }
+
+ private int sidebarListsBottom() {
+ return Math.max(sidebarAreaLabelY() + 120, height - 78);
+ }
+
+ private int sidebarSectionHeight() {
+ return Math.max(60, (sidebarListsBottom() - sidebarAreaLabelY() - GAP) / 2);
+ }
+
+ private int sidebarScreenLabelY() {
+ return sidebarAreaLabelY() + sidebarSectionHeight() + GAP;
+ }
+
+ private int sidebarAreaViewportBottom() {
+ return Math.max(sidebarAreaViewportTop() + 1, sidebarAreaLabelY() + sidebarSectionHeight());
+ }
+
+ private int sidebarScreenViewportTop() {
+ return sidebarScreenLabelY() + 16;
+ }
+
+ private int sidebarScreenViewportBottom() {
+ return Math.max(sidebarScreenViewportTop() + 1, sidebarListsBottom());
+ }
+
+ private int sidebarAreaViewportHeight() {
+ return Math.max(1, sidebarAreaViewportBottom() - sidebarAreaViewportTop());
+ }
+
+ private int sidebarScreenViewportHeight() {
+ return Math.max(1, sidebarScreenViewportBottom() - sidebarScreenViewportTop());
+ }
+
+ private int estimateContentHeight(int mainW) {
+ int offset = contentTop(mainW) - contentViewportTop(mainW);
+ return switch (tab) {
+ case CREATE_EDIT -> estimateCreateEditHeight(offset);
+ case PLAYBACK -> estimatePlaybackHeight(offset);
+ case SCREEN_SETTINGS -> estimateScreenSettingsHeight(offset);
+ case DIAGNOSTICS -> offset + 320;
+ };
+ }
+
+ private int estimateCreateEditHeight(int offset) {
+ VideoCreationEditor.Draft draft = editor.draft();
+ if (draft.operation == VideoCreationEditor.Operation.CREATE_AREA) {
+ return offset + 196;
+ }
+ return offset + (draft.screenMode == VideoCreationEditor.ScreenMode.RECTANGLE ? 368 : 336);
+ }
+
+ private int estimatePlaybackHeight(int offset) {
+ ClientVideoScreen screen = selectedPlaybackScreen();
+ int queueRows = screen == null ? 1 : Math.max(1, screen.infos.size());
+ int queueY = playbackQueueY(0);
+ return Math.max(offset + queueY, offset + queueY + 18 + queueRows * 12);
+ }
+
+ private int estimateScreenSettingsHeight(int offset) {
+ ClientVideoScreen screen = selectedScreen();
+ int entries = screen == null ? 0 : screen.metadata.entries().size();
+ int displayHeight = SCREEN_SETTINGS_META_Y;
+ int metaHeight = SCREEN_SETTINGS_META_CONTENT_Y + Math.max(138, 156 + Math.max(1, entries) * 12);
+ return offset + Math.max(displayHeight, metaHeight);
+ }
+
+ private void addTabs(int x, int y, int width) {
+ int gap = 4;
+ int buttonW = Math.max(48, (width - gap * 3) / 4);
+ addTabButton(Tab.CREATE_EDIT, x, y, buttonW);
+ addTabButton(Tab.PLAYBACK, x + buttonW + gap, y, buttonW);
+ addTabButton(Tab.SCREEN_SETTINGS, x + (buttonW + gap) * 2, y, buttonW);
+ addTabButton(Tab.DIAGNOSTICS, x + (buttonW + gap) * 3, y, buttonW);
+ }
+
+ private void addTabButton(Tab target, int x, int y, int width) {
+ VpButtonWidget button = button(target.label(), x, y, width, () -> {
+ if (diagnosticsReview != null && target != Tab.DIAGNOSTICS) diagnosticsReview.releaseScreen();
+ tab = target;
+ contentScroll = 0;
+ if (target != Tab.PLAYBACK && target != Tab.SCREEN_SETTINGS) closeOverlays();
+ if (target != Tab.PLAYBACK) {
+ danmakuOverlayOpen = false;
+ biliLocalQualityOverlayOpen = false;
+ ccSubtitleOverlayOpen = false;
+ }
+ if (target != Tab.SCREEN_SETTINGS) {
+ biliScreenQualityOverlayOpen = false;
+ youtubeScreenQualityOverlay = false;
+ }
+ reopen(null);
+ }).selected(tab == target);
+ button.active = tab != target;
+ }
+
+ private void addSidebar(int x) {
+ List areas = areaNames();
+ areaScrollContentHeight = areas.size() * ROW_HEIGHT;
+ int row = sidebarAreaViewportTop() - areaScroll;
+ widgetGroup = WidgetGroup.AREA_SCROLL;
+ for (String areaName : areas) {
+ VpButtonWidget button = button(areaName, x, row, SIDEBAR_WIDTH, () -> {
+ selectedAreaName = areaName;
+ selectedScreenName = firstScreenName(selectedAreaName);
+ screenScroll = 0;
+ confirmDeleteArea = false;
+ confirmDeleteScreen = false;
+ syncDraftFromSelection(false);
+ reopen(null);
+ }).selected(areaName.equals(selectedAreaName));
+ button.active = !areaName.equals(selectedAreaName);
+ row += ROW_HEIGHT;
+ }
+
+ List screens = screensForSelectedArea();
+ screenScrollContentHeight = screens.size() * ROW_HEIGHT;
+ row = sidebarScreenViewportTop() - screenScroll;
+ widgetGroup = WidgetGroup.SCREEN_SCROLL;
+ for (ClientVideoScreen screen : screens) {
+ VpButtonWidget button = button(screen.name, x, row, SIDEBAR_WIDTH, () -> {
+ selectedScreenName = screen.name;
+ confirmDeleteScreen = false;
+ syncDraftFromSelection(true);
+ reopen(null);
+ }).selected(screen.name.equals(selectedScreenName));
+ button.active = !screen.name.equals(selectedScreenName);
+ row += ROW_HEIGHT;
+ }
+
+ widgetGroup = WidgetGroup.FIXED;
+ int bottom = height - 70;
+ VpButtonWidget deleteScreen = button(confirmDeleteScreen
+ ? VpTexts.tr("button.videoplayer.confirm_delete_screen", "Confirm Delete Screen")
+ : VpTexts.tr("button.videoplayer.delete_screen", "Delete Screen"), x, bottom, SIDEBAR_WIDTH, this::deleteSelectedScreen)
+ .danger(true)
+ .selected(confirmDeleteScreen);
+ deleteScreen.active = selectedScreen() != null && canScreen(VideoPermissionAction.REMOVE_SCREEN, selectedScreen());
+ VpButtonWidget deleteArea = button(confirmDeleteArea
+ ? VpTexts.tr("button.videoplayer.confirm_delete_area", "Confirm Delete Area")
+ : VpTexts.tr("button.videoplayer.delete_area", "Delete Area"), x, bottom + 24, SIDEBAR_WIDTH, this::deleteSelectedArea)
+ .danger(true)
+ .selected(confirmDeleteArea);
+ deleteArea.active = selectedArea() != null && canArea(VideoPermissionAction.REMOVE_AREA, selectedArea());
+ }
+
+ private void initCreateEdit(int x, int y, int width) {
+ clearSphereFields();
+ int row = y;
+ int contentW = Math.max(180, width);
+ int operationW = Math.max(86, (contentW - GAP * 2) / 3);
+ button(operationLabel(VideoCreationEditor.Operation.CREATE_AREA), x, row, operationW, () -> {
+ editor.draft().operation = VideoCreationEditor.Operation.CREATE_AREA;
+ editor.draft().name = editor.suggestedAreaName();
+ syncDraftFromSelection(false);
+ reopen(null);
+ }).selected(editor.draft().operation == VideoCreationEditor.Operation.CREATE_AREA);
+ button(operationLabel(VideoCreationEditor.Operation.CREATE_SCREEN), x + operationW + GAP, row, operationW, () -> {
+ editor.draft().operation = VideoCreationEditor.Operation.CREATE_SCREEN;
+ editor.draft().areaName = selectedAreaName == null ? "" : selectedAreaName;
+ editor.draft().name = editor.suggestedScreenName(editor.draft().areaName);
+ syncDraftFromSelection(false);
+ reopen(null);
+ }).selected(editor.draft().operation == VideoCreationEditor.Operation.CREATE_SCREEN);
+ VpButtonWidget editButton = button(operationLabel(VideoCreationEditor.Operation.EDIT_SCREEN_GEOMETRY), x + (operationW + GAP) * 2, row, operationW, () -> {
+ editor.draft().operation = VideoCreationEditor.Operation.EDIT_SCREEN_GEOMETRY;
+ syncDraftFromSelection(true);
+ reopen(null);
+ }).selected(editor.draft().operation == VideoCreationEditor.Operation.EDIT_SCREEN_GEOMETRY);
+ editButton.active = selectedScreen() != null;
+
+ VideoCreationEditor.Draft draft = editor.draft();
+ row += 44;
+ if (draft.operation == VideoCreationEditor.Operation.CREATE_AREA) {
+ nameField = textField(x, row, Math.min(260, contentW), draft.name.isBlank() ? editor.suggestedAreaName() : draft.name, VideoScreen.MAX_NAME_BYTES, VideoScreen::validNameInput);
+ row += FORM_ROW_GAP;
+ int buttonW = actionButtonWidth(contentW, 3);
+ button(selectionButtonText(), x, row, buttonW, this::toggleSelection).selected(editor.selecting());
+ button(VpTexts.tr("button.videoplayer.clear_selection", "Clear Selection"), x + buttonW + GAP, row, buttonW, () -> {
+ editor.clearSelection();
+ reopen(null);
+ });
+ VpButtonWidget create = button(VpTexts.tr("button.videoplayer.create_area", "Create Area"), x + (buttonW + GAP) * 2, row, buttonW, button -> {
+ copyCreateEditFieldsToDraft();
+ editor.confirm(result -> closeOnOk(button, result));
+ });
+ create.active = editor.ready();
+ return;
+ }
+
+ ClientVideoArea area = selectedArea();
+ ClientVideoScreen screen = selectedScreen();
+ int nameW = Math.min(260, contentW);
+ if (draft.operation == VideoCreationEditor.Operation.CREATE_SCREEN) {
+ nameField = textField(x, row, nameW, draft.name.isBlank() ? editor.suggestedScreenName(selectedAreaName) : draft.name, VideoScreen.MAX_NAME_BYTES, VideoScreen::validNameInput);
+ } else {
+ nameField = textField(x, row, nameW, selectedScreenName == null ? "" : selectedScreenName, VideoScreen.MAX_NAME_BYTES, VideoScreen::validNameInput);
+ nameField.active = false;
+ }
+ row += FORM_ROW_GAP;
+ int sourceButtonW = 72;
+ int sourceW = Math.max(72, Math.min(260, contentW - sourceButtonW - GAP));
+ sourceField = textField(x, row, sourceW, draft.operation == VideoCreationEditor.Operation.EDIT_SCREEN_GEOMETRY && screen != null ? safe(screen.source) : safe(draft.source), VideoScreen.MAX_NAME_BYTES, VideoScreen::validNameInput);
+ button(VpTexts.tr("button.videoplayer.select", "Select"), x + sourceW + GAP, row, sourceButtonW, this::cycleSource).active = area != null;
+ row += FORM_ROW_GAP;
+ int typeW = actionButtonWidth(contentW, 2);
+ Component surfaceLabel = draft.operation == VideoCreationEditor.Operation.CREATE_SCREEN
+ ? VpTexts.tr("label.videoplayer.surface_after_create", "After Create: %s", VpTexts.text(draft.surface.translation()).getString())
+ : VpTexts.tr("label.videoplayer.display_surface", "Display: %s", VpTexts.text(draft.surface.translation()).getString());
+ VpButtonWidget draftSurface = button(surfaceLabel, x, row, typeW, () -> {
+ copyCreateEditFieldsToDraft();
+ VideoCreationEditor.Draft d = editor.draft();
+ if (d.surface == ScreenSurface.FLAT) {
+ d.surface = ScreenSurface.SPHERE_360;
+ } else {
+ d.surface = ScreenSurface.FLAT;
+ }
+ reopenPreservingDraft();
+ }).selected(draft.surface == ScreenSurface.SPHERE_360);
+ draftSurface.active = draft.surface == ScreenSurface.SPHERE_360 || draft.spherePreset;
+ button(VpTexts.tr("label.videoplayer.video_mode", "Video: %s", draft.stereo3d ? "3D" : "2D"), x + typeW + GAP, row, typeW, () -> {
+ draft.stereo3d = !draft.stereo3d;
+ editor.draft().stereo3d = draft.stereo3d;
+ reopenPreservingDraft();
+ }).selected(draft.stereo3d);
+ row += FORM_ROW_GAP;
+
+ button(VpTexts.tr("label.videoplayer.mode_value", "Mode: %s", draft.screenMode.label()), x, row, Math.min(260, contentW), () -> {
+ draft.screenMode = draft.screenMode.next();
+ editor.draft().screenMode = draft.screenMode;
+ reopenPreservingDraft();
+ });
+ row += FORM_ROW_GAP;
+ if (draft.screenMode == VideoCreationEditor.ScreenMode.RECTANGLE) {
+ int transformW = actionButtonWidth(contentW, 3);
+ button(VpTexts.tr("label.videoplayer.rotation_value", "Rotation: %sdeg", draft.rectangleRotation * 90), x, row, transformW, () -> {
+ draft.rectangleRotation = Math.floorMod(draft.rectangleRotation + 1, 4);
+ editor.draft().rectangleRotation = draft.rectangleRotation;
+ reopenPreservingDraft();
+ }).selected(draft.rectangleRotation != 0);
+ button(VpTexts.tr("label.videoplayer.flip_horizontal", "Horizontal Flip: %s", onOff(draft.rectangleFlipHorizontal).getString()), x + transformW + GAP, row, transformW, () -> {
+ draft.rectangleFlipHorizontal = !draft.rectangleFlipHorizontal;
+ editor.draft().rectangleFlipHorizontal = draft.rectangleFlipHorizontal;
+ reopenPreservingDraft();
+ }).selected(draft.rectangleFlipHorizontal);
+ button(VpTexts.tr("label.videoplayer.flip_vertical", "Vertical Flip: %s", onOff(draft.rectangleFlipVertical).getString()), x + (transformW + GAP) * 2, row, transformW, () -> {
+ draft.rectangleFlipVertical = !draft.rectangleFlipVertical;
+ editor.draft().rectangleFlipVertical = draft.rectangleFlipVertical;
+ reopenPreservingDraft();
+ }).selected(draft.rectangleFlipVertical);
+ row += FORM_ROW_GAP;
+ }
+
+ int presetW = actionButtonWidth(contentW, 3);
+ button(VpTexts.tr("label.videoplayer.sphere_preset_value", "360 Preset: %s", onOff(draft.spherePreset).getString()), x, row, presetW, () -> {
+ copyCreateEditFieldsToDraft();
+ VideoCreationEditor.Draft d = editor.draft();
+ if (d.spherePreset) {
+ d.spherePreset = false;
+ d.sphereCenter = null;
+ if (d.surface == ScreenSurface.SPHERE_360) d.surface = ScreenSurface.FLAT;
+ if (editor.selectingSpherePreset()) editor.clearSelection();
+ } else {
+ ensureSpherePresetDefaults(d);
+ }
+ reopenPreservingDraft();
+ }).selected(draft.spherePreset);
+ if (draft.spherePreset) {
+ VpButtonWidget pickSphere = button(VpTexts.tr("button.videoplayer.pick_sphere_center_radius", "Pick Center/Radius"), x + presetW + GAP, row, presetW, () -> {
+ copyCreateEditFieldsToDraft();
+ ensureSpherePresetDefaults(editor.draft());
+ editor.beginSpherePresetSelection(editor.draft());
+ }).selected(editor.selectingSpherePreset());
+ pickSphere.active = area != null && !draft.sphereSkybox;
+ button(VpTexts.tr("button.videoplayer.clear_preset", "Clear Preset"), x + (presetW + GAP) * 2, row, presetW, () -> {
+ copyCreateEditFieldsToDraft();
+ editor.draft().spherePreset = false;
+ editor.draft().sphereCenter = null;
+ if (editor.draft().surface == ScreenSurface.SPHERE_360) editor.draft().surface = ScreenSurface.FLAT;
+ if (editor.selectingSpherePreset()) editor.clearSelection();
+ reopenPreservingDraft();
+ });
+ row += PARAM_ROW_GAP;
+
+ int smallW = actionButtonWidth(contentW, 3);
+ sphereLatField = textField(x, row, smallW, String.valueOf(draft.sphereLat), 4);
+ sphereLonField = textField(x + smallW + GAP, row, smallW, String.valueOf(draft.sphereLon), 4);
+ button(VpTexts.tr("label.videoplayer.skybox_value", "Skybox: %s", onOff(draft.sphereSkybox).getString()), x + (smallW + GAP) * 2, row, smallW, () -> {
+ copyCreateEditFieldsToDraft();
+ boolean skybox = !editor.draft().sphereSkybox;
+ editor.draft().sphereSkybox = skybox;
+ if (skybox && editor.selectingSpherePreset()) editor.clearSelection();
+ reopenPreservingDraft();
+ }).selected(draft.sphereSkybox);
+ row += PARAM_ROW_GAP;
+
+ int centerW = Math.max(36, (contentW - GAP * 3) / 4);
+ sphereCenterXField = textField(x, row, centerW, draft.sphereCenter == null ? "" : format(draft.sphereCenter.x), 16);
+ sphereCenterYField = textField(x + centerW + GAP, row, centerW, draft.sphereCenter == null ? "" : format(draft.sphereCenter.y), 16);
+ sphereCenterZField = textField(x + (centerW + GAP) * 2, row, centerW, draft.sphereCenter == null ? "" : format(draft.sphereCenter.z), 16);
+ sphereRadiusField = textField(x + (centerW + GAP) * 3, row, centerW, format(draft.sphereRadius), 16);
+ boolean spherePlacementActive = !draft.sphereSkybox;
+ sphereCenterXField.active = spherePlacementActive;
+ sphereCenterYField.active = spherePlacementActive;
+ sphereCenterZField.active = spherePlacementActive;
+ sphereRadiusField.active = spherePlacementActive;
+ row += PARAM_ROW_GAP;
+
+ sphereRotXField = textField(x, row, smallW, format(draft.sphereRotX), 16);
+ sphereRotYField = textField(x + smallW + GAP, row, smallW, format(draft.sphereRotY), 16);
+ sphereRotZField = textField(x + (smallW + GAP) * 2, row, smallW, format(draft.sphereRotZ), 16);
+ row += FORM_ROW_GAP;
+ } else {
+ row += FORM_ROW_GAP;
+ }
+
+ int actionCount = draft.operation == VideoCreationEditor.Operation.EDIT_SCREEN_GEOMETRY ? 4 : 3;
+ int buttonW = actionButtonWidth(contentW, actionCount);
+ VpButtonWidget select = button(selectionButtonText(), x, row, buttonW, this::toggleSelection).selected(editor.selecting());
+ select.active = editor.selecting() || canSelectForDraft(draft, area, screen);
+ button(VpTexts.tr("button.videoplayer.clear_selection", "Clear Selection"), x + buttonW + GAP, row, buttonW, () -> {
+ editor.clearSelection();
+ reopen(null);
+ });
+ VpButtonWidget confirm = button(draft.operation == VideoCreationEditor.Operation.CREATE_SCREEN
+ ? VpTexts.tr("button.videoplayer.create_screen", "Create Screen")
+ : VpTexts.tr("button.videoplayer.save_geometry", "Save Geometry"), x + (buttonW + GAP) * 2, row, buttonW, button -> {
+ copyCreateEditFieldsToDraft();
+ editor.confirm(result -> closeOnOk(button, result));
+ });
+ confirm.active = editor.ready() && canSubmitDraft(draft, area, screen);
+ if (draft.operation == VideoCreationEditor.Operation.EDIT_SCREEN_GEOMETRY) {
+ VpButtonWidget configSave = button(VpTexts.tr("button.videoplayer.save_config", "Save Config"), x + (buttonW + GAP) * 3, row, buttonW, this::saveScreenConfig);
+ configSave.active = canSaveScreenConfig(screen, draft) && canScreen(VideoPermissionAction.UPDATE_SCREEN, screen);
+ }
+ }
+
+ private void initPlayback(int x, int y, int width) {
+ int row = y;
+ int contentW = Math.max(180, width);
+ ClientVideoScreen selected = selectedScreen();
+ int playButtonW = contentW < 260 ? 58 : 72;
+ int idleListButtonW = contentW < 260 ? 72 : 84;
+ int urlW = Math.max(70, contentW - playButtonW - idleListButtonW - GAP * 2);
+ urlField = textField(x, row, urlW, "", VideoScreen.MAX_PLAY_URL_BYTES, VideoScreen::validPlayUrlInput);
+ VpButtonWidget play = button(VpTexts.tr("button.videoplayer.play", "Play"), x + urlW + GAP, row, playButtonW, button -> {
+ ClientVideoScreen screen = selectedScreen();
+ if (screen == null || urlField.getValue().isBlank()) return;
+ ClientPacketHandler.request(screen.getScreen(), urlField.getValue().trim(), permissionFeedback(button));
+ });
+ play.active = selected != null && canScreen(VideoPermissionAction.PLAY, selected.getScreen());
+ VpButtonWidget idleList = button(VpTexts.tr("button.videoplayer.idle_list", "Idle List"), x + urlW + GAP + playButtonW + GAP, row, idleListButtonW, () -> {
+ ClientVideoScreen screen = selectedPlaybackScreen();
+ if (screen != null && minecraft != null) minecraft.gui.setScreen(new IdlePlayListScreen(this, screen));
+ });
+ idleList.active = selected != null && canScreen(VideoPermissionAction.SET_IDLE_PLAY, selectedPlaybackScreen());
+ row += BUTTON_ROW_GAP;
+ int modeW = actionButtonWidth(contentW, 2);
+ VpButtonWidget stereo = button(VpTexts.tr("label.videoplayer.video_mode", "Video: %s", selected == null || !selected.stereo3d ? "2D" : "3D"), x, row, modeW, this::togglePlaybackStereo)
+ .selected(selected != null && selected.stereo3d);
+ stereo.active = selected != null && canScreen(VideoPermissionAction.UPDATE_SCREEN, selected);
+ VpButtonWidget surface = button(VpTexts.tr("label.videoplayer.display_surface", "Display: %s",
+ selected == null ? VpTexts.tr("label.videoplayer.surface.flat", "Flat").getString() : VpTexts.text(selected.surface.translation()).getString()),
+ x + modeW + GAP, row, modeW, this::togglePlaybackSurface)
+ .selected(selected != null && selected.surface == ScreenSurface.SPHERE_360);
+ surface.active = selected != null && canScreen(VideoPermissionAction.UPDATE_SCREEN, selected);
+ row += BUTTON_ROW_GAP;
+ int buttonW = actionButtonWidth(contentW, 3);
+ VpButtonWidget voteSkip = button(VpTexts.tr("button.videoplayer.vote_skip", "Vote Skip"), x, row, buttonW, button -> {
+ ClientVideoScreen screen = selectedScreen();
+ if (screen != null) ClientPacketHandler.skip(screen.getScreen(), false, permissionFeedback(button));
+ });
+ voteSkip.active = selected != null && canScreen(VideoPermissionAction.VOTE_SKIP, selected.getScreen());
+ VpButtonWidget forceSkip = button(VpTexts.tr("button.videoplayer.force_skip", "Force Skip"), x + buttonW + GAP, row, buttonW, button -> {
+ ClientVideoScreen screen = selectedScreen();
+ if (screen != null) ClientPacketHandler.skip(screen.getScreen(), true, permissionFeedback(button));
+ });
+ forceSkip.active = selected != null && canScreen(VideoPermissionAction.FORCE_SKIP, selected.getScreen());
+ VpButtonWidget sync = button(VpTexts.tr("button.videoplayer.sync_progress", "Sync Progress"), x + (buttonW + GAP) * 2, row, buttonW, button -> {
+ ClientVideoScreen screen = selectedScreen();
+ if (screen != null) ClientPacketHandler.sync(screen.getScreen(), permissionFeedback(button));
+ });
+ sync.active = selected != null && canScreen(VideoPermissionAction.SYNC, selected.getScreen());
+ row += BUTTON_ROW_GAP;
+ int sliderW = actionButtonWidth(contentW, 2);
+ slider(VpTexts.tr("label.videoplayer.brightness", "Brightness"), x, row, sliderW, VideoPlayerClient.config.brightness, value -> {
+ VideoPlayerClient.config.brightness = value;
+ });
+ ClientVideoScreen playbackScreen = selectedPlaybackScreen();
+ if (usesMpvPlaybackVolume(playbackScreen)) {
+ slider(VpTexts.tr("label.videoplayer.volume", "Volume"), x + sliderW + GAP, row, sliderW, playbackScreen == null ? 100 : playbackScreen.volume, value -> {
+ ClientVideoScreen current = selectedPlaybackScreen();
+ if (current == null) return;
+ current.volume = Math.clamp(value, 0, 100);
+ ScreenVolumeCache.put(current, current.volume);
+ if (current.player instanceof VideoPlayer player) player.setVolume(current.volume);
+ }, ignored -> {
+ });
+ } else {
+ slider(VpTexts.tr("label.videoplayer.volume", "Volume"), x + sliderW + GAP, row, sliderW, VideoPlayerClient.config.volume, value -> {
+ VideoPlayerClient.config.volume = value;
+ VideoPlayerClient.applyConfiguredVolume();
+ });
+ }
+ addPlaybackProgressSlider(x, width);
+ addPlaybackBottomControls(x, width);
+ }
+
+ private void addPlaybackProgressSlider(int x, int width) {
+ WidgetGroup previous = widgetGroup;
+ widgetGroup = WidgetGroup.FIXED;
+ playbackProgressSlider = progressSlider(x, playbackProgressY(), Math.max(120, width),
+ this::playbackProgressState,
+ this::previewPlaybackProgress,
+ this::commitPlaybackProgress,
+ this::beginPlaybackProgressDrag,
+ this::endPlaybackProgressDrag);
+ widgetGroup = previous;
+ }
+
+ private void addPlaybackBottomControls(int x, int width) {
+ WidgetGroup previous = widgetGroup;
+ widgetGroup = WidgetGroup.FIXED;
+ int row = playbackBottomControlsY();
+ int contentW = Math.max(180, width);
+ int pinW = CONTROL_HEIGHT;
+ int qualityW = Math.max(86, Math.min(150, (contentW - pinW - GAP) / 4));
+ int qualityX = x + contentW - qualityW;
+ int pinX = x + contentW - pinW;
+ qualityX = pinX - GAP - qualityW;
+ int leftW = Math.max(0, qualityX - x - GAP);
+ int ccW = Math.max(44, Math.min(112, leftW / 3));
+ int danmakuToggleW = Math.max(34, Math.min(150, leftW - ccW - CONTROL_HEIGHT - GAP * 2));
+ int leftControlsW = danmakuToggleW + CONTROL_HEIGHT + ccW + GAP * 2;
+ if (leftControlsW > leftW) {
+ int overflow = leftControlsW - leftW;
+ int ccShrink = Math.min(overflow, Math.max(0, ccW - 34));
+ ccW -= ccShrink;
+ overflow -= ccShrink;
+ danmakuToggleW = Math.max(34, danmakuToggleW - overflow);
+ }
+ VpButtonWidget danmaku = button(VpTexts.tr("label.videoplayer.danmaku_value", "Danmaku: %s", onOff(ClientDanmakuController.isGlobalEnabled()).getString()), x, row, danmakuToggleW, () -> {
+ ClientDanmakuController.toggleGlobal();
+ reopen(null);
+ }).selected(ClientDanmakuController.isGlobalEnabled());
+ danmaku.active = true;
+ int danmakuSettingsX = x + danmakuToggleW + GAP;
+ VpButtonWidget danmakuSettings = squareButton(VpTexts.tr("button.videoplayer.danmaku_short", "D"), danmakuSettingsX, row, () -> {
+ danmakuOverlayOpen = !danmakuOverlayOpen;
+ biliLocalQualityOverlayOpen = false;
+ biliScreenQualityOverlayOpen = false;
+ youtubeScreenQualityOverlay = false;
+ rebuildWidgets();
+ }).selected(danmakuOverlayOpen);
+ danmakuSettings.active = true;
+ if (danmakuOverlayOpen) {
+ initDanmakuOverlay(danmakuSettings.getRight(), row - 220, x, x + contentW);
+ }
+ ClientVideoScreen playbackScreen = selectedPlaybackScreen();
+ int ccX = danmakuSettings.getRight() + GAP;
+ VpButtonWidget ccSubtitle = button(ccSubtitleButtonText(playbackScreen), ccX, row, ccW, () -> {
+ boolean open = !ccSubtitleOverlayOpen;
+ ccSubtitleOverlayOpen = open;
+ if (open) biliQualityOverlayScroll = 0;
+ danmakuOverlayOpen = false;
+ biliLocalQualityOverlayOpen = false;
+ biliScreenQualityOverlayOpen = false;
+ youtubeScreenQualityOverlay = false;
+ rebuildWidgets();
+ }).selected(ccSubtitleOverlayOpen || (playbackScreen != null && playbackScreen.subtitles().hasSelectedTrack()));
+ ccSubtitle.active = playbackScreen != null && playbackScreen.subtitles().availableForCurrentVideo();
+ if (ccSubtitleOverlayOpen && ccSubtitle.active) {
+ initCcSubtitleOverlay(ccSubtitle.getRight(), row - 144, x, x + contentW);
+ } else if (ccSubtitleOverlayOpen) {
+ ccSubtitleOverlayOpen = false;
+ }
+ VpButtonWidget quality = button(localBiliQualityButtonText(), qualityX, row, qualityW, () -> {
+ boolean open = !biliLocalQualityOverlayOpen;
+ biliLocalQualityOverlayOpen = open;
+ if (open) biliQualityOverlayScroll = 0;
+ danmakuOverlayOpen = false;
+ ccSubtitleOverlayOpen = false;
+ biliScreenQualityOverlayOpen = false;
+ youtubeScreenQualityOverlay = false;
+ rebuildWidgets();
+ }).selected(biliLocalQualityOverlayOpen);
+ quality.active = currentBiliInfo(playbackScreen) != null || currentYouTubeInfo(playbackScreen) != null;
+ if (biliLocalQualityOverlayOpen && quality.active) {
+ initBiliLocalQualityOverlay(quality.getRight(), row - 144, x, x + contentW);
+ } else if (biliLocalQualityOverlayOpen) {
+ biliLocalQualityOverlayOpen = false;
+ }
+ VpButtonWidget pin = squareButton("钉", pinX, row, () -> {
+ playbackPreviewPinned = !playbackPreviewPinned;
+ rebuildWidgets();
+ }).selected(playbackPreviewPinned);
+ ClientVideoScreen pinnedScreen = selectedPlaybackScreen();
+ pin.active = pinnedScreen != null && pinnedScreen.player != null;
+ widgetGroup = previous;
+ }
+
+ private void initScreenSettings(int x, int y, int width) {
+ initDisplay(x, y + SCREEN_SETTINGS_DISPLAY_Y, width);
+ initMeta(x, y + SCREEN_SETTINGS_META_CONTENT_Y, width);
+ }
+
+ private void initReconnectServerButton(int x, int width) {
+ VpButtonWidget reconnect = button(VpTexts.tr("button.videoplayer.reconnect_server", "Reconnect Server"), x,
+ reconnectServerButtonY(), Math.max(180, width), VideoPlayerClient::reconnectServer);
+ reconnect.active = minecraft != null && minecraft.player != null && minecraft.getConnection() != null;
+ }
+
+ private void initDiagnostics(int x, int y, int width) {
+ ClientVideoScreen screen = selectedScreen();
+ int buttonW = actionButtonWidth(Math.max(180, width), 2);
+ diagnosticsRefreshButton = button(VpTexts.tr("button.videoplayer.refresh_diagnostics", "Refresh"), x, y,
+ buttonW, () -> requestDiagnostics(screen));
+ diagnosticsMuteButton = button(diagnosticsMuteText(), x + buttonW + GAP, y, buttonW, () -> {
+ if (diagnosticsReview != null) {
+ diagnosticsReview.toggleMute();
+ diagnosticsMuteButton.setMessage(diagnosticsMuteText());
+ diagnosticsMuteButton.selected(diagnosticsReview.muted());
+ }
+ }).selected(diagnosticsReview != null && diagnosticsReview.muted());
+ diagnosticsMuteButton.active = diagnosticsReview != null && selectedPlaybackScreen() != null;
+ updateDiagnosticsRefreshButton(screen);
+ }
+
+ private void initDisplay(int x, int y, int width) {
+ ClientVideoScreen screen = selectedScreen();
+ int contentW = Math.max(180, width);
+ int row = y;
+ int displayModeW = actionButtonWidth(contentW, 2);
+ VpButtonWidget stretch = button(VpTexts.tr("button.videoplayer.stretch", "Stretch"), x, row, displayModeW, button -> {
+ ClientVideoScreen s = selectedScreen();
+ if (s != null) setScaleAndRefresh(s, true, 1, 1, permissionFeedback(button));
+ }).selected(screen != null && screen.fill);
+ stretch.active = screen != null && canScreen(VideoPermissionAction.SET_SCALE, screen);
+ VpButtonWidget auto = button(VpTexts.tr("button.videoplayer.auto_aspect", "Auto Aspect"), x + displayModeW + GAP, row, displayModeW, button -> {
+ ClientVideoScreen s = selectedScreen();
+ if (s != null) setScaleAndRefresh(s, false, 1, 1, permissionFeedback(button));
+ }).selected(screen != null && !screen.fill);
+ auto.active = screen != null && canScreen(VideoPermissionAction.SET_SCALE, screen);
+ row += FORM_ROW_GAP;
+ VpButtonWidget showIdleImage = button(VpTexts.tr("label.videoplayer.show_idle_image_value", "Idle Image: %s", boolLabel(screen, ScreenMetadata.KEY_SHOW_IDLE_IMAGE, true).getString()), x, row, contentW, button -> toggleMeta(button, ScreenMetadata.KEY_SHOW_IDLE_IMAGE, true))
+ .selected(screen != null && screen.metadata.getBool(ScreenMetadata.KEY_SHOW_IDLE_IMAGE, true));
+ showIdleImage.active = screen != null && canScreen(VideoPermissionAction.SET_METADATA, screen);
+ row += FORM_ROW_GAP;
+ VpButtonWidget danmaku = button(VpTexts.tr("label.videoplayer.danmaku_value", "Danmaku: %s", boolLabel(screen, ScreenMetadata.KEY_DANMAKU_ENABLED, true).getString()), x, row, contentW, button -> toggleMeta(button, ScreenMetadata.KEY_DANMAKU_ENABLED, true))
+ .selected(screen != null && screen.metadata.getBool(ScreenMetadata.KEY_DANMAKU_ENABLED, true));
+ danmaku.active = screen != null && canScreen(VideoPermissionAction.SET_METADATA, screen);
+ row += FORM_ROW_GAP;
+ VpButtonWidget biliQuality = button(screenBiliQualityButtonText(screen), x, row, contentW, () -> {
+ boolean open = !biliScreenQualityOverlayOpen || youtubeScreenQualityOverlay;
+ biliScreenQualityOverlayOpen = open;
+ youtubeScreenQualityOverlay = false;
+ if (open) biliQualityOverlayScroll = 0;
+ danmakuOverlayOpen = false;
+ biliLocalQualityOverlayOpen = false;
+ rebuildWidgets();
+ }).selected(biliScreenQualityOverlayOpen && !youtubeScreenQualityOverlay);
+ biliQuality.active = screen != null && canScreen(VideoPermissionAction.SET_METADATA, screen);
+ if (biliScreenQualityOverlayOpen && !youtubeScreenQualityOverlay && biliQuality.active) {
+ initBiliScreenQualityOverlay(biliQuality.getRight(), row + CONTROL_HEIGHT + 6, x, x + contentW);
+ } else if (biliScreenQualityOverlayOpen && !youtubeScreenQualityOverlay && !biliQuality.active) {
+ biliScreenQualityOverlayOpen = false;
+ }
+ row += FORM_ROW_GAP;
+ VpButtonWidget youtubeQuality = button(screenYouTubeQualityButtonText(screen), x, row, contentW, () -> {
+ boolean open = !biliScreenQualityOverlayOpen || !youtubeScreenQualityOverlay;
+ biliScreenQualityOverlayOpen = open;
+ youtubeScreenQualityOverlay = open;
+ if (open) biliQualityOverlayScroll = 0;
+ danmakuOverlayOpen = false;
+ biliLocalQualityOverlayOpen = false;
+ rebuildWidgets();
+ }).selected(biliScreenQualityOverlayOpen && youtubeScreenQualityOverlay);
+ youtubeQuality.active = screen != null && canScreen(VideoPermissionAction.SET_METADATA, screen);
+ if (biliScreenQualityOverlayOpen && youtubeScreenQualityOverlay && youtubeQuality.active) {
+ initYouTubeScreenQualityOverlay(youtubeQuality.getRight(), row + CONTROL_HEIGHT + 6, x, x + contentW);
+ } else if (biliScreenQualityOverlayOpen && youtubeScreenQualityOverlay && !youtubeQuality.active) {
+ biliScreenQualityOverlayOpen = false;
+ youtubeScreenQualityOverlay = false;
+ }
+ row += FORM_ROW_GAP;
+ VpButtonWidget mapping = button(VpTexts.tr("button.videoplayer.open_mapping_editor", "Open Mapping Editor"), x, row, contentW, () -> {
+ ClientVideoScreen selected = selectedScreen();
+ if (selected != null && selected.fill) minecraft.gui.setScreen(new VideoMappingScreen(this, selected));
+ });
+ mapping.active = screen != null && screen.fill && screen.vertices.size() >= 3 && canScreen(VideoPermissionAction.SET_METADATA, screen);
+ row += FORM_ROW_GAP;
+ int scaleSliderW = actionButtonWidth(contentW, 2);
+ VpSliderWidget scaleX = slider("Scale X", x, row, scaleSliderW, scaleToSliderValue(screen == null ? 1 : screen.scaleX), value -> {
+ }, value -> setScreenScaleX(sliderValueToScale(value)), value -> "Scale X: " + format(sliderValueToScale(value)));
+ scaleX.active = screen != null && canScreen(VideoPermissionAction.SET_SCALE, screen);
+ VpSliderWidget scaleY = slider("Scale Y", x + scaleSliderW + GAP, row, scaleSliderW, scaleToSliderValue(screen == null ? 1 : screen.scaleY), value -> {
+ }, value -> setScreenScaleY(sliderValueToScale(value)), value -> "Scale Y: " + format(sliderValueToScale(value)));
+ scaleY.active = screen != null && canScreen(VideoPermissionAction.SET_SCALE, screen);
+ }
+
+ private void initMeta(int x, int y, int width) {
+ ClientVideoScreen screen = selectedScreen();
+ int contentW = Math.max(180, width);
+ int row = y;
+ int toggleW = actionButtonWidth(contentW, 3);
+ VpButtonWidget mute = button(VpTexts.tr("label.videoplayer.mute_value", "Mute: %s", boolLabel(screen, "mute", false).getString()), x, row, toggleW, button -> toggleMeta(button, "mute", false))
+ .selected(screen != null && screen.metadata.getBool("mute", false));
+ mute.active = screen != null && canScreen(VideoPermissionAction.SET_METADATA, screen);
+ VpButtonWidget interactable = button(VpTexts.tr("label.videoplayer.interactable_value", "Interactable: %s", boolLabel(screen, "interactable", true).getString()), x + toggleW + GAP, row, toggleW, button -> toggleMeta(button, "interactable", true))
+ .selected(screen != null && screen.metadata.getBool("interactable", true));
+ interactable.active = screen != null && canScreen(VideoPermissionAction.SET_METADATA, screen);
+ VpButtonWidget autoSync = button(VpTexts.tr("label.videoplayer.auto_sync_value", "Auto Sync: %s", boolLabel(screen, "autoSync", false).getString()), x + (toggleW + GAP) * 2, row, toggleW, button -> toggleMeta(button, "autoSync", false))
+ .selected(screen != null && screen.metadata.getBool("autoSync", false));
+ autoSync.active = screen != null && canScreen(VideoPermissionAction.SET_METADATA, screen);
+
+ row += 30;
+ VpSliderWidget defaultVolume = slider(VpTexts.tr("label.videoplayer.default_volume", "Default Volume"), x, row, contentW, screen == null ? 100 : screen.defaultVolume(), value -> {
+ }, this::setDefaultVolume);
+ defaultVolume.active = screen != null && canScreen(VideoPermissionAction.SET_METADATA, screen);
+
+ row += 36;
+ int keyW = Math.max(100, Math.min(220, (contentW - GAP) / 2));
+ int typeW = Math.max(86, Math.min(120, contentW - keyW - GAP));
+ customKeyField = textField(x, row, keyW, "", 64);
+ VpButtonWidget type = button(VpTexts.tr("label.videoplayer.type_value", "Type: %s", customMetaType.label()), x + keyW + GAP, row, typeW, this::cycleCustomMetaType);
+ type.active = screen != null;
+
+ row += 30;
+ int setW = 72;
+ int removeW = 72;
+ int valueW = Math.max(100, contentW - setW - removeW - GAP * 2);
+ customValueField = textField(x, row, valueW, defaultValueFor(customMetaType), MetaValue.MAX_STRING_BYTES);
+ VpButtonWidget set = button(VpTexts.tr("button.videoplayer.set", "Set"), x + valueW + GAP, row, setW, button -> setCustomMeta(button, false));
+ set.active = screen != null && canScreen(VideoPermissionAction.SET_METADATA, screen);
+ VpButtonWidget remove = button(VpTexts.tr("button.videoplayer.remove", "Remove"), x + valueW + GAP + setW + GAP, row, removeW, button -> setCustomMeta(button, true)).danger(true);
+ remove.active = screen != null && canScreen(VideoPermissionAction.SET_METADATA, screen);
+ }
+
+ private void drawSidebarLabels(GuiGraphicsExtractor context, int x) {
+ drawLabel(context, "Area", x, sidebarAreaLabelY(), THEME.primaryTextColor());
+ drawLabel(context, "Screen", x, sidebarScreenLabelY(), THEME.primaryTextColor());
+ }
+
+ private void drawTabContent(GuiGraphicsExtractor context, int x, int y, int width, int mouseX, int mouseY) {
+ switch (tab) {
+ case CREATE_EDIT -> drawCreateEdit(context, x, y);
+ case PLAYBACK -> drawPlayback(context, x, y, mouseX, mouseY);
+ case SCREEN_SETTINGS -> drawScreenSettings(context, x, y, width);
+ case DIAGNOSTICS -> drawDiagnostics(context, x, y, width);
+ }
+ }
+
+ private void renderContent(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta, int x, int y, int width) {
+ context.enableScissor(x, contentViewportTop(), x + width, contentViewportBottom());
+ drawTabContent(context, x, y, width, mouseX, mouseY);
+ if (!hidePlaybackScrollpane(mouseX, mouseY)) {
+ renderDrawables(context, contentScrollDrawables, mouseX, mouseY, delta);
+ }
+ context.disableScissor();
+ }
+
+ private void renderClippedDrawables(GuiGraphicsExtractor context, List drawables, int mouseX, int mouseY, float delta,
+ int left, int top, int right, int bottom) {
+ context.enableScissor(left, top, right, bottom);
+ renderDrawables(context, drawables, mouseX, mouseY, delta);
+ context.disableScissor();
+ }
+
+ private void renderDrawables(GuiGraphicsExtractor context, List drawables, int mouseX, int mouseY, float delta) {
+ for (Renderable drawable : drawables) {
+ drawable.extractRenderState(context, mouseX, mouseY, delta);
+ }
+ }
+
+ private void renderActiveOverlay(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
+ if (!overlayOpen() || danmakuOverlayW <= 0 || danmakuOverlayH <= 0) {
+ return;
+ }
+ if (danmakuOverlayOpen) {
+ renderDanmakuOverlay(context, mouseX, mouseY, delta);
+ } else if (ccSubtitleOverlayOpen) {
+ renderCcSubtitleOverlay(context, mouseX, mouseY, delta);
+ } else {
+ renderBiliQualityOverlay(context, mouseX, mouseY, delta);
+ }
+ }
+
+ private void renderDanmakuOverlay(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
+ if (!danmakuOverlayOpen || tab != Tab.PLAYBACK || danmakuOverlayW <= 0 || danmakuOverlayH <= 0) {
+ return;
+ }
+ VpUiRenderer.drawBox(context, danmakuOverlayX, danmakuOverlayY, danmakuOverlayW, danmakuOverlayH,
+ VpUiRenderer.withAlpha(VpUiRenderer.darken(THEME.panelBackgroundColor(), 0.04f), 0xF2),
+ THEME.panelBorderColor());
+ int innerX = danmakuOverlayX + 10;
+ boolean showDensity = showDanmakuDensityControls();
+ drawLabel(context, VpTexts.tr("label.videoplayer.danmaku_settings", "Danmaku Settings"), innerX, danmakuOverlayY + 8, THEME.primaryTextColor());
+ drawLabel(context, VpTexts.tr("label.videoplayer.display_range", "Display Range"), innerX, danmakuOverlayY + 47, THEME.secondaryTextColor());
+ if (showDensity) {
+ drawLabel(context, VpTexts.tr("label.videoplayer.danmaku_density", "Danmaku Density"), innerX, danmakuOverlayY + 80, THEME.secondaryTextColor());
+ }
+ drawLabel(context, VpTexts.tr("label.videoplayer.rolling_speed", "Rolling Speed"), innerX, danmakuOverlayY + (showDensity ? 113 : 80), THEME.secondaryTextColor());
+ renderDrawables(context, danmakuOverlayDrawables, mouseX, mouseY, delta);
+ }
+
+ private void renderBiliQualityOverlay(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
+ if (!biliLocalQualityOverlayOpen && !biliScreenQualityOverlayOpen) {
+ return;
+ }
+ VpUiRenderer.drawBox(context, danmakuOverlayX, danmakuOverlayY, danmakuOverlayW, danmakuOverlayH,
+ VpUiRenderer.withAlpha(VpUiRenderer.darken(THEME.panelBackgroundColor(), 0.04f), 0xF2),
+ THEME.panelBorderColor());
+ Component title;
+ if (biliLocalQualityOverlayOpen) {
+ title = currentYouTubeInfo(selectedPlaybackScreen()) != null
+ ? VpTexts.tr("label.videoplayer.youtube_quality.local", "YouTube Quality")
+ : VpTexts.tr("label.videoplayer.bili_quality.local", "Bili Quality");
+ } else {
+ title = youtubeScreenQualityOverlay
+ ? VpTexts.tr("label.videoplayer.youtube_quality.screen_limit", "YouTube Limit")
+ : VpTexts.tr("label.videoplayer.bili_quality.screen_limit", "Bili Limit");
+ }
+ drawLabel(context, title, danmakuOverlayX + 10, danmakuOverlayY + 8, THEME.primaryTextColor());
+ int left = danmakuOverlayX + BILI_QUALITY_OVERLAY_PADDING;
+ int right = danmakuOverlayX + danmakuOverlayW - BILI_QUALITY_OVERLAY_PADDING;
+ context.enableScissor(left, biliQualityOverlayViewportTop, right, biliQualityOverlayViewportBottom);
+ renderDrawables(context, danmakuOverlayDrawables, mouseX, mouseY, delta);
+ context.disableScissor();
+ drawScrollbar(context, right - 4, biliQualityOverlayViewportTop, biliQualityOverlayViewportBottom, biliQualityOverlayScroll, biliQualityOverlayContentHeight);
+ }
+
+ private void renderCcSubtitleOverlay(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
+ if (!ccSubtitleOverlayOpen) {
+ return;
+ }
+ VpUiRenderer.drawBox(context, danmakuOverlayX, danmakuOverlayY, danmakuOverlayW, danmakuOverlayH,
+ VpUiRenderer.withAlpha(VpUiRenderer.darken(THEME.panelBackgroundColor(), 0.04f), 0xF2),
+ THEME.panelBorderColor());
+ drawLabel(context, VpTexts.tr("label.videoplayer.cc_subtitle", "CC Subtitles"), danmakuOverlayX + 10, danmakuOverlayY + 8, THEME.primaryTextColor());
+ int left = danmakuOverlayX + BILI_QUALITY_OVERLAY_PADDING;
+ int right = danmakuOverlayX + danmakuOverlayW - BILI_QUALITY_OVERLAY_PADDING;
+ context.enableScissor(left, biliQualityOverlayViewportTop, right, biliQualityOverlayViewportBottom);
+ renderDrawables(context, danmakuOverlayDrawables, mouseX, mouseY, delta);
+ context.disableScissor();
+ drawScrollbar(context, right - 4, biliQualityOverlayViewportTop, biliQualityOverlayViewportBottom, biliQualityOverlayScroll, biliQualityOverlayContentHeight);
+ }
+
+ private boolean insideActiveOverlay(double mouseX, double mouseY) {
+ return overlayOpen()
+ && danmakuOverlayW > 0
+ && danmakuOverlayH > 0
+ && inside(mouseX, mouseY, danmakuOverlayX, danmakuOverlayY, danmakuOverlayX + danmakuOverlayW, danmakuOverlayY + danmakuOverlayH);
+ }
+
+ private boolean overlayOpen() {
+ return tab == Tab.PLAYBACK && (danmakuOverlayOpen || biliLocalQualityOverlayOpen || ccSubtitleOverlayOpen)
+ || tab == Tab.SCREEN_SETTINGS && biliScreenQualityOverlayOpen;
+ }
+
+ private boolean biliQualityOverlayOpen() {
+ return biliLocalQualityOverlayOpen || biliScreenQualityOverlayOpen;
+ }
+
+ private boolean scrollableOverlayOpen() {
+ return biliQualityOverlayOpen() || ccSubtitleOverlayOpen;
+ }
+
+ private void closeOverlays() {
+ danmakuOverlayOpen = false;
+ biliLocalQualityOverlayOpen = false;
+ biliScreenQualityOverlayOpen = false;
+ youtubeScreenQualityOverlay = false;
+ ccSubtitleOverlayOpen = false;
+ }
+
+ private boolean scrollBiliQualityOverlay(int delta) {
+ int viewportHeight = Math.max(1, biliQualityOverlayViewportBottom - biliQualityOverlayViewportTop);
+ int next = clampScroll(biliQualityOverlayScroll + delta, biliQualityOverlayContentHeight, viewportHeight);
+ if (next == biliQualityOverlayScroll) {
+ return true;
+ }
+ biliQualityOverlayScroll = next;
+ rebuildBiliQualityOverlayAtCurrentPosition();
+ return true;
+ }
+
+ private void rebuildBiliQualityOverlayAtCurrentPosition() {
+ int anchorRight = danmakuOverlayX + danmakuOverlayW;
+ int anchorY = danmakuOverlayY;
+ for (AbstractWidget widget : danmakuOverlayWidgets) {
+ removeWidget(widget);
+ }
+ danmakuOverlayDrawables.clear();
+ danmakuOverlayWidgets.clear();
+ activeDanmakuOverlayWidget = null;
+ if (biliLocalQualityOverlayOpen) {
+ initBiliLocalQualityOverlay(anchorRight, anchorY, mainX(), mainX() + mainW());
+ } else if (biliScreenQualityOverlayOpen) {
+ if (youtubeScreenQualityOverlay) {
+ initYouTubeScreenQualityOverlay(anchorRight, anchorY, mainX(), mainX() + mainW());
+ } else {
+ initBiliScreenQualityOverlay(anchorRight, anchorY, mainX(), mainX() + mainW());
+ }
+ } else if (ccSubtitleOverlayOpen) {
+ initCcSubtitleOverlay(anchorRight, anchorY, mainX(), mainX() + mainW());
+ }
+ }
+
+ private void drawScrollbar(GuiGraphicsExtractor context, int x, int top, int bottom, int scroll, int contentHeight) {
+ int viewportHeight = Math.max(1, bottom - top);
+ int maxScroll = Math.max(0, contentHeight - viewportHeight);
+ if (maxScroll <= 0) {
+ return;
+ }
+ int trackColor = VpUiRenderer.withAlpha(VpUiRenderer.blend(THEME.panelBorderColor(), THEME.panelBackgroundColor(), 0.55f), 0x88);
+ int thumbColor = VpUiRenderer.withAlpha(VpUiRenderer.blend(THEME.secondaryTextColor(), THEME.accentColor(), 0.35f), 0xDD);
+ int thumbHeight = Math.max(14, viewportHeight * viewportHeight / Math.max(viewportHeight, contentHeight));
+ int thumbTravel = Math.max(1, viewportHeight - thumbHeight);
+ int thumbY = top + thumbTravel * Math.clamp(scroll, 0, maxScroll) / maxScroll;
+ VpUiRenderer.drawBox(context, x, top, 4, viewportHeight, trackColor, trackColor);
+ VpUiRenderer.drawBox(context, x, thumbY, 4, thumbHeight, thumbColor, thumbColor);
+ }
+
+ private void drawCreateEdit(GuiGraphicsExtractor context, int x, int y) {
+ VideoCreationEditor.Draft draft = editor.draft();
+ int contentW = Math.max(180, width - x - 14);
+ int row = y + 44;
+ if (draft.operation == VideoCreationEditor.Operation.CREATE_AREA) {
+ drawLabel(context, VpTexts.tr("label.videoplayer.name", "Name"), x, row - LABEL_OFFSET, THEME.secondaryTextColor());
+ row += FORM_ROW_GAP;
+ drawLabel(context, VpTexts.tr("label.videoplayer.selection_points", "Selection: %s", editor.pointProgress()), x, row - LABEL_OFFSET, THEME.secondaryTextColor());
+ } else {
+ drawLabel(context, VpTexts.tr("label.videoplayer.name", "Name"), x, row - LABEL_OFFSET, THEME.secondaryTextColor());
+ row += FORM_ROW_GAP;
+ drawLabel(context, "Source", x, row - LABEL_OFFSET, THEME.secondaryTextColor());
+ row += FORM_ROW_GAP;
+ drawLabel(context, draft.operation == VideoCreationEditor.Operation.CREATE_SCREEN
+ ? VpTexts.tr("label.videoplayer.display_after_create_video", "Display After Create / Video")
+ : VpTexts.tr("label.videoplayer.display_video", "Display / Video"), x, row - LABEL_OFFSET, THEME.secondaryTextColor());
+ row += FORM_ROW_GAP;
+ drawLabel(context, VpTexts.tr("label.videoplayer.mode", "Mode"), x, row - LABEL_OFFSET, THEME.secondaryTextColor());
+ row += FORM_ROW_GAP;
+ if (draft.screenMode == VideoCreationEditor.ScreenMode.RECTANGLE) {
+ drawLabel(context, VpTexts.tr("label.videoplayer.rectangle_direction", "Rectangle Direction"), x, row - LABEL_OFFSET, THEME.secondaryTextColor());
+ row += FORM_ROW_GAP;
+ }
+ drawLabel(context, VpTexts.tr("label.videoplayer.sphere_preset", "360 Preset"), x, row - LABEL_OFFSET, THEME.secondaryTextColor());
+ if (draft.spherePreset) {
+ row += PARAM_ROW_GAP;
+
+ int smallW = actionButtonWidth(contentW, 3);
+ drawLabel(context, "Lat", x, row - LABEL_OFFSET, THEME.secondaryTextColor());
+ drawLabel(context, "Lon", x + smallW + GAP, row - LABEL_OFFSET, THEME.secondaryTextColor());
+ drawLabel(context, VpTexts.tr("label.videoplayer.skybox", "Skybox"), x + (smallW + GAP) * 2, row - LABEL_OFFSET, THEME.secondaryTextColor());
+ row += PARAM_ROW_GAP;
+
+ int centerW = Math.max(36, (contentW - GAP * 3) / 4);
+ drawLabel(context, "X", x, row - LABEL_OFFSET, THEME.secondaryTextColor());
+ drawLabel(context, "Y", x + centerW + GAP, row - LABEL_OFFSET, THEME.secondaryTextColor());
+ drawLabel(context, "Z", x + (centerW + GAP) * 2, row - LABEL_OFFSET, THEME.secondaryTextColor());
+ drawLabel(context, "Radius", x + (centerW + GAP) * 3, row - LABEL_OFFSET, THEME.secondaryTextColor());
+ row += PARAM_ROW_GAP;
+
+ drawLabel(context, "Rot X", x, row - LABEL_OFFSET, THEME.secondaryTextColor());
+ drawLabel(context, "Rot Y", x + smallW + GAP, row - LABEL_OFFSET, THEME.secondaryTextColor());
+ drawLabel(context, "Rot Z", x + (smallW + GAP) * 2, row - LABEL_OFFSET, THEME.secondaryTextColor());
+ row += FORM_ROW_GAP;
+ } else {
+ row += FORM_ROW_GAP;
+ }
+
+ drawLabel(context, VpTexts.tr("label.videoplayer.selection_points", "Selection: %s", editor.pointProgress()), x, row - LABEL_OFFSET, THEME.secondaryTextColor());
+ }
+ int color = editor.statusError() ? THEME.errorColor() : THEME.executionColor();
+ int statusY = draft.operation == VideoCreationEditor.Operation.CREATE_AREA ? y + 186 : row + 22;
+ drawLabel(context, editor.status(), x, statusY, color);
+ trackContentBottom(statusY + 10);
+ }
+
+ private void drawPlayback(GuiGraphicsExtractor context, int x, int y, int mouseX, int mouseY) {
+ if (showPlaybackProgressPreview(mouseX, mouseY)) {
+ drawPlaybackProgressPreview(context, x);
+ trackContentBottom(contentViewportBottom());
+ return;
+ }
+ drawLabel(context, "URL", x, y - 12, THEME.secondaryTextColor());
+ ClientVideoScreen screen = selectedPlaybackScreen();
+ int queueY = playbackQueueY(y);
+ drawLabel(context, VpTexts.tr("label.videoplayer.queue", "Queue"), x, queueY, THEME.secondaryTextColor());
+ if (screen == null || screen.infos.isEmpty()) {
+ drawLabel(context, VpTexts.tr("message.videoplayer.queue_empty", "Queue is empty"), x, queueY + 18, THEME.secondaryTextColor());
+ trackContentBottom(queueY + 28);
+ return;
+ }
+ int row = queueY + 18;
+ int index = 1;
+ for (VideoInfo info : screen.infos) {
+ drawLabel(context, index + ". " + info.name() + " / " + info.playerName(), x, row, THEME.secondaryTextColor());
+ row += 12;
+ index++;
+ }
+ trackContentBottom(row + 4);
+ }
+
+ private int playbackQueueY(int y) {
+ return y + BUTTON_ROW_GAP * 3 + CONTROL_HEIGHT + 14;
+ }
+
+ private VpProgressSliderWidget.ProgressState playbackProgressState() {
+ ClientVideoScreen screen = selectedPlaybackScreen();
+ if (screen == null || screen.player == null) {
+ return VpProgressSliderWidget.ProgressState.disabled();
+ }
+ long total = screen.player.getTotalProgress();
+ long progress = screen.player.getProgress();
+ if (canSeekPlayback(screen)) {
+ return VpProgressSliderWidget.ProgressState.of(progress, total);
+ }
+ return VpProgressSliderWidget.ProgressState.readonly(total);
+ }
+
+ private boolean canSeekPlayback(ClientVideoScreen screen) {
+ if (screen == null || screen.player == null) return false;
+ if (!ClientPermissionCache.allowedForScreen(VideoPermissionAction.SEEK, screen)) return false;
+ VideoInfo info = screen.currentDisplayInfo();
+ return info != null && info.seekable() && screen.player.canSetProgress() && screen.player.getTotalProgress() > 0;
+ }
+
+ private void beginPlaybackProgressDrag() {
+ playbackProgressPreview = true;
+ lastPlaybackPreviewSeekTime = 0;
+ playbackProgressDragScreen = selectedPlaybackScreen();
+ playbackProgressPausedBeforeDrag = false;
+ playbackProgressPauseApplied = false;
+ if (canSeekPlayback(playbackProgressDragScreen) && playbackProgressDragScreen.player.canPause()) {
+ playbackProgressPausedBeforeDrag = playbackProgressDragScreen.player.isPaused();
+ if (!playbackProgressPausedBeforeDrag) {
+ playbackProgressDragScreen.player.pause(true);
+ playbackProgressPauseApplied = true;
+ }
+ }
+ }
+
+ private void previewPlaybackProgress(long progress) {
+ long now = System.currentTimeMillis();
+ if (lastPlaybackPreviewSeekTime != 0 && now - lastPlaybackPreviewSeekTime < PLAYBACK_SEEK_THROTTLE_MS) {
+ return;
+ }
+ seekPlaybackLocal(clampPlaybackPreviewProgress(progress));
+ lastPlaybackPreviewSeekTime = now;
+ }
+
+ private void commitPlaybackProgress(long progress) {
+ ClientVideoScreen screen = selectedPlaybackScreen();
+ if (!canSeekPlayback(screen)) return;
+ seekPlaybackLocal(progress);
+ ClientPacketHandler.seek(screen, progress);
+ }
+
+ private void endPlaybackProgressDrag() {
+ if (playbackProgressPauseApplied && playbackProgressDragScreen != null && playbackProgressDragScreen.player != null && !playbackProgressPausedBeforeDrag) {
+ playbackProgressDragScreen.player.pause(false);
+ }
+ playbackProgressPreview = false;
+ playbackProgressDragScreen = null;
+ playbackProgressPausedBeforeDrag = false;
+ playbackProgressPauseApplied = false;
+ lastPlaybackPreviewSeekTime = 0;
+ }
+
+ private void seekPlaybackLocal(long progress) {
+ ClientVideoScreen screen = selectedPlaybackScreen();
+ if (!canSeekPlayback(screen)) return;
+ screen.setProgress(progress);
+ }
+
+ private long clampPlaybackPreviewProgress(long progress) {
+ ClientVideoScreen screen = selectedPlaybackScreen();
+ if (!canSeekPlayback(screen)) return Math.max(0, progress);
+ long total = screen.player.getTotalProgress();
+ if (total <= 0) return Math.max(0, progress);
+ long guard = Math.min(PLAYBACK_PREVIEW_END_GUARD_MS, Math.max(1, total / 20));
+ long maxPreview = Math.max(0, total - guard);
+ return Math.clamp(progress, 0, maxPreview);
+ }
+
+ private boolean drawPlaybackProgressPreview(GuiGraphicsExtractor context, int x) {
+ ClientVideoScreen screen = selectedPlaybackScreen();
+ if (screen == null || screen.player == null) return false;
+ int textureId = screen.displayTextureId();
+ if (textureId < 0) return false;
+ int areaTop = contentViewportTop() + 10;
+ int areaBottom = contentViewportBottom() - 10;
+ int areaH = Math.max(1, areaBottom - areaTop);
+ int areaW = Math.max(1, mainW() - 8);
+ int textureW = Math.max(1, screen.displayTextureWidth());
+ int textureH = Math.max(1, screen.displayTextureHeight());
+ float aspect = textureW / (float) textureH;
+ int previewW = areaW;
+ int previewH = Math.round(previewW / aspect);
+ if (previewH > areaH) {
+ previewH = areaH;
+ previewW = Math.round(previewH * aspect);
+ }
+ int previewX = x + Math.max(0, (areaW - previewW) / 2);
+ int previewY = areaTop + Math.max(0, (areaH - previewH) / 2);
+ VpUiRenderer.drawBox(context, previewX - 3, previewY - 3, previewW + 6, previewH + 6,
+ VpUiRenderer.darken(THEME.nodeBodyColor(), 0.08f), THEME.panelBorderColor());
+ drawPlaybackTexture(context, screen, textureId, previewX, previewY, previewW, previewH);
+ ClientDanmakuRenderer.drawPreview(context, screen, previewX, previewY, previewW, previewH);
+ ClientDanmakuRenderer.drawSubtitlePreview(context, screen, previewX, previewY, previewW, previewH);
+ context.outline(previewX - 1, previewY - 1, previewW + 2, previewH + 2, THEME.panelBorderColor());
+ return true;
+ }
+
+ private void drawPlaybackTexture(GuiGraphicsExtractor context, ClientVideoScreen screen, int textureId, int x, int y, int width, int height) {
+ float u2 = screen != null && screen.stereo3d ? 0.5f : 1f;
+ context.blit(
+ ScreenRenderer.textureIdentifier(textureId),
+ x,
+ y,
+ x + width,
+ y + height,
+ 0,
+ u2,
+ 0,
+ 1
+ );
+ }
+
+ private void drawScreenSettings(GuiGraphicsExtractor context, int x, int y, int width) {
+ VideoConnectionDiagnostics.Snapshot connection = VideoPlayerClient.connectionSnapshot();
+ int contentW = Math.max(180, width);
+ Component address = VpTexts.tr("label.videoplayer.server_address", "Server: %s", connectionAddress(connection));
+ Component status = connectionStatus(connection);
+ drawLabel(context, VpTexts.tr("label.videoplayer.server_connection", "Server Connection"), x, y, THEME.primaryTextColor());
+ drawLabel(context, trimToWidth(address.getString(), contentW), x,
+ y + SCREEN_SETTINGS_CONNECTION_ADDRESS_Y, THEME.secondaryTextColor());
+ drawLabel(context, trimToWidth(status.getString(), contentW), x,
+ y + SCREEN_SETTINGS_CONNECTION_STATUS_Y, connectionStatusColor(connection.state()));
+ drawLabel(context, VpTexts.tr("label.videoplayer.display", "Display"), x,
+ y + SCREEN_SETTINGS_DISPLAY_LABEL_Y, THEME.primaryTextColor());
+ drawDisplay(context, x, y + SCREEN_SETTINGS_DISPLAY_Y);
+ drawLabel(context, "Meta", x, y + SCREEN_SETTINGS_META_Y, THEME.primaryTextColor());
+ drawMeta(context, x, y + SCREEN_SETTINGS_META_CONTENT_Y, width);
+ }
+
+ private void drawDiagnostics(GuiGraphicsExtractor context, int x, int y, int width) {
+ int contentW = Math.max(180, width);
+ int row = y + 30;
+ ClientVideoScreen screen = selectedScreen();
+ drawLabel(context, VpTexts.tr("label.videoplayer.diagnostics", "Diagnostics"), x, row, THEME.primaryTextColor());
+ row += 16;
+ row = drawAudioLevelGraph(context, x, row, contentW);
+ row += 10;
+ if (screen == null) {
+ drawLabel(context, VpTexts.tr("message.videoplayer.diagnostics_no_screen", "Select a screen to inspect playback"),
+ x, row, THEME.secondaryTextColor());
+ trackContentBottom(row + 12);
+ return;
+ }
+ PlaybackDiagnostics diagnostics = ClientPacketHandler.diagnostics(screen);
+ if (diagnostics == null) {
+ drawLabel(context, VpTexts.tr("message.videoplayer.diagnostics_waiting", "Waiting for server diagnostics"),
+ x, row, THEME.secondaryTextColor());
+ trackContentBottom(row + 12);
+ return;
+ }
+ row = drawDiagnosticsLine(context, x, row, contentW,
+ VpTexts.tr("label.videoplayer.diagnostics_backend", "Backend: %s", diagnosticsBackendState(diagnostics.backendState())),
+ THEME.secondaryTextColor());
+ row = drawDiagnosticsLine(context, x, row, contentW,
+ VpTexts.tr("label.videoplayer.diagnostics_connection", "Connection: %s",
+ connectionStatus(VideoPlayerClient.connectionSnapshot()).getString()), THEME.secondaryTextColor());
+ row = drawDiagnosticsLine(context, x, row, contentW,
+ VpTexts.tr("label.videoplayer.diagnostics_state", "State: %s", diagnosticsPlaybackState(diagnostics)),
+ diagnostics.playing() || diagnostics.resolving() ? THEME.executionColor() : THEME.secondaryTextColor());
+ row = drawDiagnosticsLine(context, x, row, contentW,
+ VpTexts.tr("label.videoplayer.diagnostics_current", "Current: %s", displayDiagnosticsValue(diagnostics.currentTitle())),
+ THEME.secondaryTextColor());
+ row = drawDiagnosticsLine(context, x, row, contentW,
+ VpTexts.tr("label.videoplayer.diagnostics_queue", "Queue: %s | Next: %s", diagnostics.queueSize(),
+ displayDiagnosticsValue(diagnostics.queuedTitle())), THEME.secondaryTextColor());
+ row = drawDiagnosticsLine(context, x, row, contentW,
+ VpTexts.tr("label.videoplayer.diagnostics_generation", "Generation: %s | Progress: %s", diagnostics.generation(),
+ formatDiagnosticsDuration(diagnostics.progressMs())), THEME.secondaryTextColor());
+ row = drawDiagnosticsLine(context, x, row, contentW,
+ VpTexts.tr("label.videoplayer.diagnostics_retry", "Retry: %s", diagnosticsRetryState(diagnostics)),
+ diagnostics.retryAttempt() > 0 ? THEME.accentColor() : THEME.secondaryTextColor());
+ row = drawDiagnosticsLine(context, x, row, contentW,
+ VpTexts.tr("label.videoplayer.diagnostics_client_resolution", "Client metadata: %s", diagnosticsClientResolutionState(diagnostics)),
+ diagnostics.awaitingClientResolution() ? THEME.accentColor() : THEME.secondaryTextColor());
+ String failure = diagnosticsFailure(diagnostics);
+ if (!failure.isBlank()) {
+ row = drawDiagnosticsLine(context, x, row, contentW,
+ VpTexts.tr("label.videoplayer.diagnostics_failure", "Last failure: %s", failure),
+ THEME.errorColor());
+ }
+ trackContentBottom(row + 4);
+ }
+
+ private int drawDiagnosticsLine(GuiGraphicsExtractor context, int x, int y, int width, Component text, int color) {
+ drawLabel(context, trimToWidth(text.getString(), width), x, y, color);
+ return y + 14;
+ }
+
+ private int drawAudioLevelGraph(GuiGraphicsExtractor context, int x, int y, int width) {
+ AudioLevelSnapshot level = diagnosticsReview == null
+ ? AudioLevelSnapshot.unsupported()
+ : diagnosticsReview.currentLevel();
+ drawLabel(context, VpTexts.tr("label.videoplayer.audio_level", "Video Audio Level"), x, y, THEME.primaryTextColor());
+ y += 14;
+ if (level.status() != AudioLevelSnapshot.Status.AVAILABLE) {
+ String state = switch (level.status()) {
+ case UNSUPPORTED -> VpTexts.tr("status.videoplayer.audio_level_unsupported", "Real audio level is unavailable for this backend").getString();
+ case NO_AUDIO -> VpTexts.tr("status.videoplayer.audio_level_no_audio", "Current video has no audio track").getString();
+ case WAITING -> VpTexts.tr("status.videoplayer.audio_level_waiting", "Waiting for audio level data").getString();
+ case AVAILABLE -> "";
+ };
+ drawLabel(context, trimToWidth(state, width), x, y, THEME.secondaryTextColor());
+ return y + 18;
+ }
+
+ String values = VpTexts.tr("label.videoplayer.audio_level_values", "RMS: %s dBFS | Peak: %s dBFS",
+ formatAudioDb(level.rmsDb()), formatAudioDb(level.peakDb())).getString();
+ drawLabel(context, trimToWidth(values, width), x, y, THEME.secondaryTextColor());
+ y += 14;
+
+ List history = diagnosticsReview == null ? List.of() : diagnosticsReview.history();
+ float heldPeak = level.peakDb();
+ for (AudioLevelSnapshot sample : history) heldPeak = Math.max(heldPeak, sample.peakDb());
+
+ int barHeight = 10;
+ VpUiRenderer.drawBox(context, x, y, width, barHeight, VpUiRenderer.darken(THEME.nodeBodyColor(), 0.08f), THEME.panelBorderColor());
+ int rmsWidth = Math.round(width * audioLevelRatio(level.rmsDb()));
+ if (rmsWidth > 0) context.fill(x + 1, y + 1, x + Math.max(1, rmsWidth - 1), y + barHeight - 1, THEME.executionColor());
+ int peakX = x + Math.clamp(Math.round((width - 1) * audioLevelRatio(level.peakDb())), 0, width - 1);
+ context.fill(peakX, y, Math.min(x + width, peakX + 2), y + barHeight, THEME.accentColor());
+ int heldPeakX = x + Math.clamp(Math.round((width - 1) * audioLevelRatio(heldPeak)), 0, width - 1);
+ context.fill(heldPeakX, y, Math.min(x + width, heldPeakX + 1), y + barHeight, THEME.errorColor());
+ y += barHeight + 6;
+
+ int graphHeight = 42;
+ VpUiRenderer.drawBox(context, x, y, width, graphHeight, VpUiRenderer.darken(THEME.nodeBodyColor(), 0.08f), THEME.panelBorderColor());
+ if (!history.isEmpty()) {
+ int count = history.size();
+ for (int i = 0; i < count; i++) {
+ AudioLevelSnapshot sample = history.get(i);
+ int pointX = x + 1 + Math.round((width - 3) * (i / (float) Math.max(1, count - 1)));
+ int pointY = y + graphHeight - 2 - Math.round((graphHeight - 4) * audioLevelRatio(sample.rmsDb()));
+ context.fill(pointX, pointY, Math.min(x + width - 1, pointX + 2), Math.min(y + graphHeight - 1, pointY + 2), THEME.executionColor());
+ }
+ }
+ return y + graphHeight;
+ }
+
+ private float audioLevelRatio(float db) {
+ return Math.clamp((db - AudioLevelSnapshot.MIN_DB) / (AudioLevelSnapshot.MAX_DB - AudioLevelSnapshot.MIN_DB), 0f, 1f);
+ }
+
+ private String formatAudioDb(float value) {
+ return String.format(Locale.ROOT, "%.1f", value);
+ }
+
+ private String diagnosticsPlaybackState(PlaybackDiagnostics diagnostics) {
+ if (diagnostics.resolving()) return VpTexts.tr("status.videoplayer.diagnostics.resolving", "Resolving").getString();
+ if (diagnostics.playing() && diagnostics.idle()) return VpTexts.tr("status.videoplayer.diagnostics.idle", "Playing idle media").getString();
+ if (diagnostics.playing()) return VpTexts.tr("status.videoplayer.diagnostics.playing", "Playing").getString();
+ return VpTexts.tr("status.videoplayer.diagnostics.stopped", "Stopped").getString();
+ }
+
+ private String diagnosticsRetryState(PlaybackDiagnostics diagnostics) {
+ if (diagnostics.retryAttempt() <= 0) return VpTexts.tr("label.videoplayer.diagnostics_none", "None").getString();
+ long seconds = Math.max(0L, (diagnostics.nextRetryAtMs() - System.currentTimeMillis() + 999L) / 1_000L);
+ return VpTexts.tr("label.videoplayer.diagnostics_retry_pending", "%s/%s in %ss",
+ diagnostics.retryAttempt(), 3, seconds).getString();
+ }
+
+ private String diagnosticsClientResolutionState(PlaybackDiagnostics diagnostics) {
+ if (!diagnostics.awaitingClientResolution()) {
+ return VpTexts.tr("label.videoplayer.diagnostics_not_required", "Not required").getString();
+ }
+ return diagnostics.reporterAssigned()
+ ? VpTexts.tr("label.videoplayer.diagnostics_reporter_assigned", "Reporter assigned").getString()
+ : VpTexts.tr("label.videoplayer.diagnostics_reporter_waiting", "Waiting for a reporter").getString();
+ }
+
+ private String diagnosticsBackendState(String state) {
+ if (state == null || state.isBlank()) return displayDiagnosticsValue(state);
+ return switch (state) {
+ case "INITIALIZING" -> VpTexts.tr("status.videoplayer.diagnostics.backend_initializing", "Initializing").getString();
+ case "INSTALLING" -> VpTexts.tr("status.videoplayer.diagnostics.backend_installing", "Installing").getString();
+ case "LOADING" -> VpTexts.tr("status.videoplayer.diagnostics.backend_loading", "Loading").getString();
+ case "READY" -> VpTexts.tr("status.videoplayer.diagnostics.backend_ready", "Ready").getString();
+ case "UNAVAILABLE" -> VpTexts.tr("status.videoplayer.diagnostics.backend_unavailable", "Unavailable").getString();
+ case "STOPPED" -> VpTexts.tr("status.videoplayer.diagnostics.backend_stopped", "Stopped").getString();
+ case "SERVER" -> VpTexts.tr("status.videoplayer.diagnostics.backend_server", "Server").getString();
+ default -> state;
+ };
+ }
+
+ private String diagnosticsFailure(PlaybackDiagnostics diagnostics) {
+ PlaybackFailureReason reason = diagnostics.failureReason();
+ if (reason == null || reason == PlaybackFailureReason.NONE) return diagnostics.failureMessage();
+ return switch (reason) {
+ case RESOLUTION -> VpTexts.tr("status.videoplayer.diagnostics.failure_resolution", "Unable to resolve the media source").getString();
+ case SOURCE_REJECTED -> VpTexts.tr("status.videoplayer.diagnostics.failure_source_rejected", "The resolved media source is not allowed").getString();
+ case LISTENER_START -> VpTexts.tr("status.videoplayer.diagnostics.failure_listener_start", "Unable to start the playback backend").getString();
+ case PLAYBACK_ERROR -> VpTexts.tr("status.videoplayer.diagnostics.failure_playback_error", "The playback backend reported an error").getString();
+ case PLAYBACK_TIMEOUT -> VpTexts.tr("status.videoplayer.diagnostics.failure_playback_timeout", "The playback backend timed out while loading media").getString();
+ case CLIENT_RESOLUTION -> VpTexts.tr("status.videoplayer.diagnostics.failure_client_resolution", "A client could not resolve playback metadata").getString();
+ case NONE -> diagnostics.failureMessage();
+ };
+ }
+
+ private String displayDiagnosticsValue(String value) {
+ return value == null || value.isBlank()
+ ? VpTexts.tr("label.videoplayer.diagnostics_none", "None").getString()
+ : value;
+ }
+
+ private String formatDiagnosticsDuration(long millis) {
+ if (millis < 0L) return VpTexts.tr("label.videoplayer.diagnostics_unknown", "Unknown").getString();
+ long seconds = millis / 1_000L;
+ long hours = seconds / 3_600L;
+ long minutes = (seconds % 3_600L) / 60L;
+ long remainder = seconds % 60L;
+ if (hours > 0L) return String.format(Locale.ROOT, "%d:%02d:%02d", hours, minutes, remainder);
+ return String.format(Locale.ROOT, "%d:%02d", minutes, remainder);
+ }
+
+ private void requestDiagnosticsIfDue() {
+ ClientVideoScreen screen = selectedScreen();
+ updateDiagnosticsRefreshButton(screen);
+ if (!diagnosticsAreaLoaded(screen) || diagnosticsRequestInFlight
+ || !canScreen(VideoPermissionAction.OPEN_MENU, screen)) return;
+ long now = System.currentTimeMillis();
+ if (now - lastDiagnosticsRequestAt < DIAGNOSTICS_REFRESH_INTERVAL_MS) return;
+ requestDiagnostics(screen);
+ }
+
+ private void updateDiagnosticsReview() {
+ if (diagnosticsReview == null) return;
+ if (tab == Tab.DIAGNOSTICS) {
+ diagnosticsReview.select(selectedPlaybackScreen());
+ diagnosticsReview.tick();
+ } else {
+ diagnosticsReview.releaseScreen();
+ }
+ if (diagnosticsMuteButton != null) {
+ diagnosticsMuteButton.setMessage(diagnosticsMuteText());
+ diagnosticsMuteButton.selected(diagnosticsReview.muted());
+ diagnosticsMuteButton.active = tab == Tab.DIAGNOSTICS && selectedPlaybackScreen() != null;
+ }
+ }
+
+ private Component diagnosticsMuteText() {
+ boolean muted = diagnosticsReview != null && diagnosticsReview.muted();
+ return VpTexts.tr("label.videoplayer.review_mute", "Review Mute: %s", onOff(muted).getString());
+ }
+
+ private void requestDiagnostics(ClientVideoScreen screen) {
+ if (!diagnosticsAreaLoaded(screen) || diagnosticsRequestInFlight
+ || !canScreen(VideoPermissionAction.OPEN_MENU, screen)) return;
+ diagnosticsRequestInFlight = true;
+ if (ClientPacketHandler.requestDiagnostics(screen, result -> diagnosticsRequestInFlight = false)) {
+ lastDiagnosticsRequestAt = System.currentTimeMillis();
+ } else {
+ diagnosticsRequestInFlight = false;
+ }
+ }
+
+ private boolean diagnosticsAreaLoaded(ClientVideoScreen screen) {
+ return screen != null && screen.area instanceof ClientVideoArea area && area.loaded;
+ }
+
+ private void updateDiagnosticsRefreshButton(ClientVideoScreen screen) {
+ if (diagnosticsRefreshButton != null) {
+ diagnosticsRefreshButton.active = diagnosticsAreaLoaded(screen) && !diagnosticsRequestInFlight
+ && canScreen(VideoPermissionAction.OPEN_MENU, screen);
+ }
+ }
+
+ private String connectionAddress(VideoConnectionDiagnostics.Snapshot connection) {
+ if (connection.address() == null || connection.address().isBlank()) {
+ return VpTexts.tr("label.videoplayer.connection.unknown", "Unknown").getString();
+ }
+ if ("local".equals(connection.address())) {
+ return VpTexts.tr("label.videoplayer.connection.local", "Local server").getString();
+ }
+ return connection.address();
+ }
+
+ private Component connectionStatus(VideoConnectionDiagnostics.Snapshot connection) {
+ return switch (connection.state()) {
+ case IDLE -> VpTexts.tr("status.videoplayer.connection.idle", "Waiting for a server connection");
+ case CONNECTING -> VpTexts.tr("status.videoplayer.connection.connecting", "Connecting, attempt %s", Math.max(1, connection.attempts()));
+ case CONNECTED -> VpTexts.tr("status.videoplayer.connection.connected", "Connected to VideoPlayer server %s",
+ displayConnectionVersion(connection.remoteVersion()));
+ case CHANNEL_UNAVAILABLE -> VpTexts.tr("status.videoplayer.connection.channel_unavailable", "Unavailable: server did not register the VideoPlayer channel");
+ case VERSION_MISMATCH -> VpTexts.tr("status.videoplayer.connection.version_mismatch", "Version mismatch: local %s, server %s",
+ displayConnectionVersion(connection.localVersion()), displayConnectionVersion(connection.remoteVersion()));
+ case TIMED_OUT -> VpTexts.tr("status.videoplayer.connection.timed_out", "No response after 10 seconds; retrying in background");
+ case DISCONNECTED -> VpTexts.tr("status.videoplayer.connection.disconnected", "Minecraft server disconnected");
+ };
+ }
+
+ private String displayConnectionVersion(String version) {
+ if (version != null && !version.isBlank()) return version;
+ return VpTexts.tr("label.videoplayer.connection.unknown", "Unknown").getString();
+ }
+
+ private int connectionStatusColor(VideoConnectionDiagnostics.State state) {
+ return switch (state) {
+ case CONNECTED -> THEME.executionColor();
+ case CONNECTING -> THEME.accentColor();
+ case CHANNEL_UNAVAILABLE, VERSION_MISMATCH, TIMED_OUT -> THEME.errorColor();
+ case IDLE, DISCONNECTED -> THEME.secondaryTextColor();
+ };
+ }
+
+ private void drawDisplay(GuiGraphicsExtractor context, int x, int y) {
+ int contentW = Math.max(180, width - x - 14);
+ int scaleSliderW = actionButtonWidth(contentW, 2);
+ int idleImageRow = y + FORM_ROW_GAP;
+ int danmakuRow = idleImageRow + FORM_ROW_GAP;
+ int biliQualityRow = danmakuRow + FORM_ROW_GAP;
+ int youtubeQualityRow = biliQualityRow + FORM_ROW_GAP;
+ int mappingRow = youtubeQualityRow + FORM_ROW_GAP;
+ int scaleRow = mappingRow + FORM_ROW_GAP;
+ drawLabel(context, VpTexts.tr("label.videoplayer.show_idle_image", "Show Default Image When Idle"), x, idleImageRow - LABEL_OFFSET, THEME.secondaryTextColor());
+ drawLabel(context, VpTexts.tr("label.videoplayer.danmaku", "Danmaku"), x, danmakuRow - LABEL_OFFSET, THEME.secondaryTextColor());
+ drawLabel(context, VpTexts.tr("label.videoplayer.bili_quality.screen_limit", "Bili Limit"), x, biliQualityRow - LABEL_OFFSET, THEME.secondaryTextColor());
+ drawLabel(context, VpTexts.tr("label.videoplayer.youtube_quality.screen_limit", "YouTube Limit"), x, youtubeQualityRow - LABEL_OFFSET, THEME.secondaryTextColor());
+ drawLabel(context, VpTexts.tr("label.videoplayer.vertex_mapping", "Vertex Mapping"), x, mappingRow - LABEL_OFFSET, THEME.secondaryTextColor());
+ drawLabel(context, "Scale X", x, scaleRow - LABEL_OFFSET, THEME.secondaryTextColor());
+ drawLabel(context, "Scale Y", x + scaleSliderW + GAP, scaleRow - LABEL_OFFSET, THEME.secondaryTextColor());
+ trackContentBottom(scaleRow + CONTROL_HEIGHT + 4);
+ }
+
+ private void drawMeta(GuiGraphicsExtractor context, int x, int y, int width) {
+ int contentW = Math.max(180, width);
+ int keyW = Math.max(100, Math.min(220, (contentW - GAP) / 2));
+ drawLabel(context, "Key", x, y + 56, THEME.secondaryTextColor());
+ drawLabel(context, "Type", x + keyW + GAP, y + 56, THEME.secondaryTextColor());
+ drawLabel(context, "Value", x, y + 86, THEME.secondaryTextColor());
+ ClientVideoScreen screen = selectedScreen();
+ if (screen == null) return;
+ drawLabel(context, "Metadata", x, y + 138, THEME.secondaryTextColor());
+ int row = y + 156;
+ List> entries = screen.metadata.entries().entrySet().stream()
+ .sorted(Map.Entry.comparingByKey())
+ .toList();
+ if (entries.isEmpty()) {
+ drawLabel(context, "{}", x, row, THEME.secondaryTextColor());
+ trackContentBottom(row + 12);
+ return;
+ }
+ for (Map.Entry entry : entries) {
+ MetaValue value = entry.getValue();
+ String type = value.type == null ? "unknown" : value.type.label();
+ drawLabel(context, entry.getKey() + " [" + type + "] = " + value.toDisplayString(), x, row, THEME.secondaryTextColor());
+ row += 12;
+ }
+ trackContentBottom(row + 4);
+ }
+
+ private void drawLabel(GuiGraphicsExtractor context, String label, int x, int y, int color) {
+ drawLabel(context, Component.literal(label), x, y, color);
+ }
+
+ private void drawLabel(GuiGraphicsExtractor context, Component label, int x, int y, int color) {
+ if (THEME.textShadow()) {
+ context.text(font, label, x, y, color);
+ return;
+ }
+ context.text(font, label, x, y, color, false);
+ }
+
+ private String trimToWidth(String text, int maxWidth) {
+ String value = text == null ? "" : text;
+ if (font.width(value) <= maxWidth) return value;
+ String suffix = "...";
+ return font.plainSubstrByWidth(value, Math.max(0, maxWidth - font.width(suffix))) + suffix;
+ }
+
+ private EditBox textField(int x, int y, int width, String text, int maxLength) {
+ return textField(x, y, width, text, maxLength, value -> true);
+ }
+
+ private EditBox textField(int x, int y, int width, String text, int maxLength, Predicate predicate) {
+ VpTextFieldWidget field = new VpTextFieldWidget(font, x, y, Math.max(40, width), CONTROL_HEIGHT, Component.empty(), THEME);
+ field.setMaxLength(maxLength);
+ field.setFilter(predicate);
+ field.setValue(text == null ? "" : text);
+ addRenderableWidget(field);
+ registerDrawable(field, y, CONTROL_HEIGHT);
+ return field;
+ }
+
+ private VpButtonWidget button(String label, int x, int y, int width, Runnable action) {
+ return button(Component.literal(label), x, y, width, action);
+ }
+
+ private VpButtonWidget button(Component label, int x, int y, int width, Runnable action) {
+ VpButtonWidget button = new VpButtonWidget(x, y, Math.max(34, width), CONTROL_HEIGHT, label, b -> action.run(), THEME);
+ addRenderableWidget(button);
+ registerDrawable(button, y, CONTROL_HEIGHT);
+ return button;
+ }
+
+ private VpButtonWidget button(String label, int x, int y, int width, Consumer action) {
+ return button(Component.literal(label), x, y, width, action);
+ }
+
+ private VpButtonWidget button(Component label, int x, int y, int width, Consumer action) {
+ VpButtonWidget button = new VpButtonWidget(x, y, Math.max(34, width), CONTROL_HEIGHT, label, action, THEME);
+ addRenderableWidget(button);
+ registerDrawable(button, y, CONTROL_HEIGHT);
+ return button;
+ }
+
+ private VpButtonWidget squareButton(String label, int x, int y, Runnable action) {
+ return squareButton(Component.literal(label), x, y, action);
+ }
+
+ private VpButtonWidget squareButton(Component label, int x, int y, Runnable action) {
+ VpButtonWidget button = new VpButtonWidget(x, y, CONTROL_HEIGHT, CONTROL_HEIGHT, label, ignored -> action.run(), THEME);
+ addRenderableWidget(button);
+ registerDrawable(button, y, CONTROL_HEIGHT);
+ return button;
+ }
+
+ private VpButtonWidget danmakuOverlayButton(String label, int x, int y, int width, Consumer action) {
+ return danmakuOverlayButton(Component.literal(label), x, y, width, action);
+ }
+
+ private VpButtonWidget danmakuOverlayButton(Component label, int x, int y, int width, Consumer action) {
+ VpButtonWidget button = new VpButtonWidget(x, y, Math.max(34, width), CONTROL_HEIGHT, label, action, THEME);
+ addRenderableWidget(button);
+ danmakuOverlayDrawables.add(button);
+ danmakuOverlayWidgets.add(button);
+ return button;
+ }
+
+ private VpSliderWidget danmakuOverlaySlider(String label, int x, int y, int width, int value,
+ IntConsumer action, IntConsumer commit, IntFunction messageFormatter) {
+ VpSliderWidget slider = new VpSliderWidget(x, y, Math.max(60, width), CONTROL_HEIGHT, label, value, action, commit,
+ (VpSliderWidget.TextFormatter) v -> Component.literal(messageFormatter.apply(v)), THEME);
+ addRenderableWidget(slider);
+ danmakuOverlayDrawables.add(slider);
+ danmakuOverlayWidgets.add(slider);
+ return slider;
+ }
+
+ private VpSliderWidget danmakuOverlaySlider(Component label, int x, int y, int width, int value,
+ IntConsumer action, IntConsumer commit, VpSliderWidget.TextFormatter messageFormatter) {
+ VpSliderWidget slider = new VpSliderWidget(x, y, Math.max(60, width), CONTROL_HEIGHT, "", value, action, commit, messageFormatter, THEME);
+ addRenderableWidget(slider);
+ danmakuOverlayDrawables.add(slider);
+ danmakuOverlayWidgets.add(slider);
+ return slider;
+ }
+
+ private void initDanmakuOverlay(int anchorRight, int anchorY, int minX, int maxX) {
+ normalizeDanmakuOverlayConfig();
+ boolean showDensity = showDanmakuDensityControls();
+ int availableW = Math.max(180, maxX - minX);
+ danmakuOverlayW = Math.min(DANMAKU_OVERLAY_WIDTH, availableW);
+ danmakuOverlayH = DANMAKU_OVERLAY_BASE_HEIGHT + (showDensity ? DANMAKU_OVERLAY_DENSITY_EXTRA_HEIGHT : 0);
+ int maxPanelX = Math.max(minX, maxX - danmakuOverlayW);
+ danmakuOverlayX = Math.clamp(anchorRight - danmakuOverlayW, minX, maxPanelX);
+ int maxPanelY = Math.max(contentViewportTop(), height - 18 - danmakuOverlayH);
+ danmakuOverlayY = Math.clamp(anchorY, contentViewportTop(), maxPanelY);
+
+ int padding = 10;
+ int innerX = danmakuOverlayX + padding;
+ int innerW = danmakuOverlayW - padding * 2;
+ int blockRow = danmakuOverlayY + 26;
+ int rangeRow = danmakuOverlayY + 58;
+ int densityRow = danmakuOverlayY + 91;
+ int speedRow = danmakuOverlayY + (showDensity ? 124 : 91);
+ int opacityRow = danmakuOverlayY + (showDensity ? 157 : 124);
+ int scaleRow = danmakuOverlayY + (showDensity ? 181 : 148);
+ int guardRow = danmakuOverlayY + (showDensity ? 205 : 172);
+
+ int blockW = (innerW - GAP * 2) / 3;
+ addDanmakuToggle(VpTexts.tr("label.videoplayer.block_rolling", "Block Rolling"), innerX, blockRow, blockW,
+ () -> VideoPlayerClient.config.danmakuBlockRolling,
+ value -> VideoPlayerClient.config.danmakuBlockRolling = value);
+ addDanmakuToggle(VpTexts.tr("label.videoplayer.block_fixed", "Block Fixed"), innerX + blockW + GAP, blockRow, blockW,
+ () -> VideoPlayerClient.config.danmakuBlockFixed,
+ value -> VideoPlayerClient.config.danmakuBlockFixed = value);
+ addDanmakuToggle(VpTexts.tr("label.videoplayer.block_colored", "Block Colored"), innerX + (blockW + GAP) * 2, blockRow, innerW - (blockW + GAP) * 2,
+ () -> VideoPlayerClient.config.danmakuBlockColored,
+ value -> VideoPlayerClient.config.danmakuBlockColored = value);
+
+ VpButtonWidget[] rangeButtons = new VpButtonWidget[DANMAKU_RANGE_OPTIONS.length];
+ int rangeGap = 4;
+ int rangeW = (innerW - rangeGap * (DANMAKU_RANGE_OPTIONS.length - 1)) / DANMAKU_RANGE_OPTIONS.length;
+ for (int i = 0; i < DANMAKU_RANGE_OPTIONS.length; i++) {
+ int option = DANMAKU_RANGE_OPTIONS[i];
+ int buttonX = innerX + (rangeW + rangeGap) * i;
+ int buttonW = i == DANMAKU_RANGE_OPTIONS.length - 1 ? innerX + innerW - buttonX : rangeW;
+ rangeButtons[i] = danmakuOverlayButton(option + "%", buttonX, rangeRow, buttonW, button -> {
+ boolean hadDensity = showDanmakuDensityControls();
+ VideoPlayerClient.config.danmakuRollingRangePercent = option;
+ saveDanmakuOverlayConfig();
+ if (hadDensity != showDanmakuDensityControls()) {
+ rebuildDanmakuOverlayAtCurrentPosition();
+ } else {
+ updateDanmakuRangeButtons(rangeButtons);
+ }
+ });
+ rangeButtons[i].selected(VideoPlayerClient.config.danmakuRollingRangePercent == option);
+ }
+
+ if (showDensity) {
+ VpButtonWidget[] densityButtons = new VpButtonWidget[DANMAKU_DENSITY_KEYS.length];
+ int densityGap = 4;
+ int densityW = (innerW - densityGap * (DANMAKU_DENSITY_KEYS.length - 1)) / DANMAKU_DENSITY_KEYS.length;
+ for (int i = 0; i < DANMAKU_DENSITY_KEYS.length; i++) {
+ int preset = i;
+ int buttonX = innerX + (densityW + densityGap) * i;
+ int buttonW = i == DANMAKU_DENSITY_KEYS.length - 1 ? innerX + innerW - buttonX : densityW;
+ densityButtons[i] = danmakuOverlayButton(danmakuDensityLabel(i), buttonX, densityRow, buttonW, button -> {
+ VideoPlayerClient.config.danmakuDensityPreset = preset;
+ saveDanmakuOverlayConfig();
+ updateDanmakuDensityButtons(densityButtons);
+ });
+ densityButtons[i].selected(VideoPlayerClient.config.danmakuDensityPreset == i);
+ }
+ }
+
+ VpButtonWidget[] speedButtons = new VpButtonWidget[DANMAKU_SPEED_KEYS.length];
+ int speedGap = 4;
+ int speedW = (innerW - speedGap * (DANMAKU_SPEED_KEYS.length - 1)) / DANMAKU_SPEED_KEYS.length;
+ for (int i = 0; i < DANMAKU_SPEED_KEYS.length; i++) {
+ int preset = i;
+ int buttonX = innerX + (speedW + speedGap) * i;
+ int buttonW = i == DANMAKU_SPEED_KEYS.length - 1 ? innerX + innerW - buttonX : speedW;
+ speedButtons[i] = danmakuOverlayButton(danmakuSpeedLabel(i), buttonX, speedRow, buttonW, button -> {
+ VideoPlayerClient.config.danmakuSpeedPreset = preset;
+ saveDanmakuOverlayConfig();
+ updateDanmakuSpeedButtons(speedButtons);
+ });
+ speedButtons[i].selected(VideoPlayerClient.config.danmakuSpeedPreset == i);
+ }
+
+ danmakuOverlaySlider(VpTexts.tr("label.videoplayer.opacity", "Opacity"), innerX, opacityRow, innerW, opacityToSliderValue(VideoPlayerClient.config.danmakuOpacity), value -> {
+ VideoPlayerClient.config.danmakuOpacity = sliderValueToOpacity(value);
+ saveDanmakuOverlayConfig();
+ }, value -> {
+ VideoPlayerClient.config.danmakuOpacity = sliderValueToOpacity(value);
+ saveDanmakuOverlayConfig();
+ }, value -> VpTexts.tr("label.videoplayer.opacity_percent", "Opacity: %s%%", sliderValueToOpacity(value)));
+
+ danmakuOverlaySlider(VpTexts.tr("label.videoplayer.font_size", "Font Size"), innerX, scaleRow, innerW, danmakuScaleToSliderValue(VideoPlayerClient.config.danmakuScalePercent), value -> {
+ VideoPlayerClient.config.danmakuScalePercent = sliderValueToDanmakuScale(value);
+ saveDanmakuOverlayConfig();
+ }, value -> {
+ VideoPlayerClient.config.danmakuScalePercent = sliderValueToDanmakuScale(value);
+ saveDanmakuOverlayConfig();
+ }, value -> VpTexts.tr("label.videoplayer.font_size_percent", "Font Size: %s%%", sliderValueToDanmakuScale(value)));
+
+ addDanmakuToggle(VpTexts.tr("label.videoplayer.bottom_guard", "Bottom Guard"), innerX, guardRow, innerW,
+ () -> VideoPlayerClient.config.danmakuBottomGuard,
+ value -> VideoPlayerClient.config.danmakuBottomGuard = value);
+ }
+
+ private void initBiliLocalQualityOverlay(int anchorRight, int anchorY, int minX, int maxX) {
+ if (currentYouTubeInfo(selectedPlaybackScreen()) != null) {
+ List available = currentAvailableYouTubeQualities();
+ ArrayList options = new ArrayList<>();
+ options.add(YouTubeQuality.AUTO);
+ options.addAll(available);
+ int displayQuality = displayedLocalYouTubeQuality(available);
+ initQualityOverlay(anchorRight, anchorY, minX, maxX, options, displayQuality,
+ this::selectLocalYouTubeQuality, this::youtubeQualityText);
+ return;
+ }
+ List options = currentAvailableBiliQualities();
+ int displayQuality = displayedLocalBiliQuality(options);
+ int selected = options.contains(displayQuality) ? displayQuality : Integer.MIN_VALUE;
+ initQualityOverlay(anchorRight, anchorY, minX, maxX, options, selected,
+ this::selectLocalBiliQuality, this::biliQualityText);
+ }
+
+ private void initBiliScreenQualityOverlay(int anchorRight, int anchorY, int minX, int maxX) {
+ ArrayList options = new ArrayList<>();
+ options.add(BiliQuality.UNLIMITED);
+ for (int option : BiliQuality.options()) {
+ options.add(option);
+ }
+ ClientVideoScreen screen = selectedScreen();
+ int selected = screen == null ? BiliQuality.UNLIMITED : BiliQuality.normalizeScreenLimit(screen.metadata.getInt(ScreenMetadata.KEY_BILIBILI_QUALITY, BiliQuality.UNLIMITED));
+ initQualityOverlay(anchorRight, anchorY, minX, maxX, options, selected,
+ this::selectScreenBiliQuality, this::biliQualityText);
+ }
+
+ private void initYouTubeScreenQualityOverlay(int anchorRight, int anchorY, int minX, int maxX) {
+ ArrayList options = new ArrayList<>();
+ options.add(YouTubeQuality.AUTO);
+ for (int option : YouTubeQuality.options()) {
+ options.add(option);
+ }
+ ClientVideoScreen screen = selectedScreen();
+ int selected = screen == null ? YouTubeQuality.AUTO : YouTubeQuality.normalizeScreenLimit(
+ screen.metadata.getInt(ScreenMetadata.KEY_YOUTUBE_QUALITY, YouTubeQuality.AUTO)
+ );
+ initQualityOverlay(anchorRight, anchorY, minX, maxX, options, selected,
+ this::selectScreenYouTubeQuality, this::youtubeQualityText);
+ }
+
+ private void initCcSubtitleOverlay(int anchorRight, int anchorY, int minX, int maxX) {
+ ClientVideoScreen screen = selectedPlaybackScreen();
+ if (screen == null || !screen.subtitles().availableForCurrentVideo()) {
+ closeOverlays();
+ return;
+ }
+ ClientSubtitleController subtitles = screen.subtitles();
+ ArrayList choices = new ArrayList<>();
+ choices.add(new SubtitleChoice("", VpTexts.tr("label.videoplayer.cc_subtitle.off", "Off"), true));
+ for (ClientSubtitleController.Option option : subtitles.options()) {
+ choices.add(new SubtitleChoice(option.key(), ccSubtitleOptionText(option), true));
+ }
+ if (subtitles.options().isEmpty()) {
+ Component label = subtitles.catalogLoaded()
+ ? VpTexts.tr("label.videoplayer.cc_subtitle.none", "No CC")
+ : VpTexts.tr("label.videoplayer.cc_subtitle.loading", "Loading");
+ choices.add(new SubtitleChoice("\u0000", label, false));
+ }
+ initCcSubtitleOverlay(anchorRight, anchorY, minX, maxX, choices, subtitles.selectedKey(), subtitles);
+ }
+
+ private void initCcSubtitleOverlay(int anchorRight, int anchorY, int minX, int maxX,
+ List choices, String selected, ClientSubtitleController subtitles) {
+ int availableW = Math.max(180, maxX - minX);
+ danmakuOverlayW = Math.max(BILI_QUALITY_OVERLAY_MIN_WIDTH, availableW / 4);
+ int contentHeight = choices.size() * CONTROL_HEIGHT + Math.max(0, choices.size() - 1) * BILI_QUALITY_OVERLAY_BUTTON_GAP;
+ int maxViewportHeight = BILI_QUALITY_OVERLAY_VISIBLE_ROWS * CONTROL_HEIGHT
+ + Math.max(0, BILI_QUALITY_OVERLAY_VISIBLE_ROWS - 1) * BILI_QUALITY_OVERLAY_BUTTON_GAP;
+ int viewportHeight = Math.min(contentHeight, maxViewportHeight);
+ biliQualityOverlayContentHeight = contentHeight;
+ biliQualityOverlayScroll = clampScroll(biliQualityOverlayScroll, biliQualityOverlayContentHeight, Math.max(1, viewportHeight));
+ danmakuOverlayH = BILI_QUALITY_OVERLAY_HEADER_HEIGHT + viewportHeight + BILI_QUALITY_OVERLAY_PADDING;
+ int maxPanelX = Math.max(minX, maxX - danmakuOverlayW);
+ danmakuOverlayX = Math.clamp(anchorRight - danmakuOverlayW, minX, maxPanelX);
+ int maxPanelY = Math.max(contentViewportTop(), height - 18 - danmakuOverlayH);
+ danmakuOverlayY = Math.clamp(anchorY, contentViewportTop(), maxPanelY);
+
+ biliQualityOverlayViewportTop = danmakuOverlayY + BILI_QUALITY_OVERLAY_HEADER_HEIGHT;
+ biliQualityOverlayViewportBottom = biliQualityOverlayViewportTop + viewportHeight;
+ int innerX = danmakuOverlayX + BILI_QUALITY_OVERLAY_PADDING;
+ int innerW = danmakuOverlayW - BILI_QUALITY_OVERLAY_PADDING * 2;
+ boolean needsScroll = contentHeight > viewportHeight;
+ int buttonW = innerW - (needsScroll ? 8 : 0);
+ int rowY = biliQualityOverlayViewportTop - biliQualityOverlayScroll;
+ for (int i = 0; i < choices.size(); i++) {
+ SubtitleChoice choice = choices.get(i);
+ int buttonY = rowY + i * (CONTROL_HEIGHT + BILI_QUALITY_OVERLAY_BUTTON_GAP);
+ VpButtonWidget button = danmakuOverlayButton(choice.label(), innerX, buttonY, buttonW, ignored -> {
+ subtitles.select(choice.key());
+ rebuildWidgets();
+ });
+ button.clip(innerX, biliQualityOverlayViewportTop, innerX + buttonW, biliQualityOverlayViewportBottom);
+ button.selected(Objects.equals(choice.key(), selected));
+ button.active = choice.active();
+ }
+ }
+
+ private void initQualityOverlay(int anchorRight, int anchorY, int minX, int maxX, List options,
+ int selected, IntConsumer selector, IntFunction labeler) {
+ if (options == null || options.isEmpty()) {
+ closeOverlays();
+ return;
+ }
+ int availableW = Math.max(180, maxX - minX);
+ danmakuOverlayW = Math.max(BILI_QUALITY_OVERLAY_MIN_WIDTH, availableW / 4);
+ int contentHeight = options.size() * CONTROL_HEIGHT + Math.max(0, options.size() - 1) * BILI_QUALITY_OVERLAY_BUTTON_GAP;
+ int maxViewportHeight = BILI_QUALITY_OVERLAY_VISIBLE_ROWS * CONTROL_HEIGHT
+ + Math.max(0, BILI_QUALITY_OVERLAY_VISIBLE_ROWS - 1) * BILI_QUALITY_OVERLAY_BUTTON_GAP;
+ int viewportHeight = Math.min(contentHeight, maxViewportHeight);
+ biliQualityOverlayContentHeight = contentHeight;
+ biliQualityOverlayScroll = clampScroll(biliQualityOverlayScroll, biliQualityOverlayContentHeight, Math.max(1, viewportHeight));
+ danmakuOverlayH = BILI_QUALITY_OVERLAY_HEADER_HEIGHT + viewportHeight + BILI_QUALITY_OVERLAY_PADDING;
+ int maxPanelX = Math.max(minX, maxX - danmakuOverlayW);
+ danmakuOverlayX = Math.clamp(anchorRight - danmakuOverlayW, minX, maxPanelX);
+ int maxPanelY = Math.max(contentViewportTop(), height - 18 - danmakuOverlayH);
+ danmakuOverlayY = Math.clamp(anchorY, contentViewportTop(), maxPanelY);
+
+ biliQualityOverlayViewportTop = danmakuOverlayY + BILI_QUALITY_OVERLAY_HEADER_HEIGHT;
+ biliQualityOverlayViewportBottom = biliQualityOverlayViewportTop + viewportHeight;
+ int innerX = danmakuOverlayX + BILI_QUALITY_OVERLAY_PADDING;
+ int innerW = danmakuOverlayW - BILI_QUALITY_OVERLAY_PADDING * 2;
+ boolean needsScroll = contentHeight > viewportHeight;
+ int buttonW = innerW - (needsScroll ? 8 : 0);
+ int rowY = biliQualityOverlayViewportTop - biliQualityOverlayScroll;
+ for (int i = 0; i < options.size(); i++) {
+ int option = options.get(i);
+ int buttonY = rowY + i * (CONTROL_HEIGHT + BILI_QUALITY_OVERLAY_BUTTON_GAP);
+ VpButtonWidget button = danmakuOverlayButton(labeler.apply(option), innerX, buttonY, buttonW, ignored -> selector.accept(option));
+ button.clip(innerX, biliQualityOverlayViewportTop, innerX + buttonW, biliQualityOverlayViewportBottom);
+ button.selected(option == selected);
+ }
+ }
+
+ private VpButtonWidget addDanmakuToggle(Component label, int x, int y, int width, BooleanSupplier getter, Consumer setter) {
+ VpButtonWidget button = danmakuOverlayButton(label, x, y, width, widget -> {
+ setter.accept(!getter.getAsBoolean());
+ saveDanmakuOverlayConfig();
+ widget.selected(getter.getAsBoolean());
+ });
+ return button.selected(getter.getAsBoolean());
+ }
+
+ private void updateDanmakuRangeButtons(VpButtonWidget[] buttons) {
+ for (int i = 0; i < buttons.length; i++) {
+ buttons[i].selected(VideoPlayerClient.config.danmakuRollingRangePercent == DANMAKU_RANGE_OPTIONS[i]);
+ }
+ }
+
+ private void updateDanmakuSpeedButtons(VpButtonWidget[] buttons) {
+ for (int i = 0; i < buttons.length; i++) {
+ buttons[i].selected(VideoPlayerClient.config.danmakuSpeedPreset == i);
+ }
+ }
+
+ private void updateDanmakuDensityButtons(VpButtonWidget[] buttons) {
+ for (int i = 0; i < buttons.length; i++) {
+ buttons[i].selected(VideoPlayerClient.config.danmakuDensityPreset == i);
+ }
+ }
+
+ private boolean showDanmakuDensityControls() {
+ return VideoPlayerClient.config != null && VideoPlayerClient.config.danmakuRollingRangePercent == 100;
+ }
+
+ private void rebuildDanmakuOverlayAtCurrentPosition() {
+ int anchorRight = danmakuOverlayX + danmakuOverlayW;
+ int anchorY = danmakuOverlayY;
+ for (AbstractWidget widget : danmakuOverlayWidgets) {
+ removeWidget(widget);
+ }
+ danmakuOverlayDrawables.clear();
+ danmakuOverlayWidgets.clear();
+ activeDanmakuOverlayWidget = null;
+ initDanmakuOverlay(anchorRight, anchorY, mainX(), mainX() + mainW());
+ }
+
+ private void saveDanmakuOverlayConfig() {
+ normalizeDanmakuOverlayConfig();
+ VideoPlayerClient.saveConfig();
+ }
+
+ private void normalizeDanmakuOverlayConfig() {
+ VideoPlayerClient.config.danmakuRollingRangePercent = switch (VideoPlayerClient.config.danmakuRollingRangePercent) {
+ case 25, 50, 75, 100 -> VideoPlayerClient.config.danmakuRollingRangePercent;
+ default -> 50;
+ };
+ VideoPlayerClient.config.danmakuSpeedPreset = Math.clamp(VideoPlayerClient.config.danmakuSpeedPreset, 0, DANMAKU_SPEED_KEYS.length - 1);
+ VideoPlayerClient.config.danmakuDensityPreset = Math.clamp(VideoPlayerClient.config.danmakuDensityPreset, 0, DANMAKU_DENSITY_KEYS.length - 1);
+ VideoPlayerClient.config.danmakuOpacity = Math.clamp(VideoPlayerClient.config.danmakuOpacity, 20, 100);
+ VideoPlayerClient.config.danmakuScalePercent = Math.clamp(VideoPlayerClient.config.danmakuScalePercent, 50, 170);
+ }
+
+ private int opacityToSliderValue(int opacity) {
+ int clamped = Math.clamp(opacity, 20, 100);
+ return Math.clamp(Math.round((clamped - 20) * 100.0f / 80.0f), 0, 100);
+ }
+
+ private int sliderValueToOpacity(int value) {
+ return Math.clamp(20 + Math.round(Math.clamp(value, 0, 100) * 80.0f / 100.0f), 20, 100);
+ }
+
+ private int danmakuScaleToSliderValue(int scale) {
+ int clamped = Math.clamp(scale, 50, 170);
+ return Math.clamp(Math.round((clamped - 50) * 100.0f / 120.0f), 0, 100);
+ }
+
+ private int sliderValueToDanmakuScale(int value) {
+ return Math.clamp(50 + Math.round(Math.clamp(value, 0, 100) * 120.0f / 100.0f), 50, 170);
+ }
+
+ private Component localBiliQualityButtonText() {
+ if (currentYouTubeInfo(selectedPlaybackScreen()) != null) {
+ List available = currentAvailableYouTubeQualities();
+ int quality = displayedLocalYouTubeQuality(available);
+ return VpTexts.tr("label.videoplayer.youtube_quality.local_value", "YouTube: %s", youtubeQualityText(quality).getString());
+ }
+ if (currentBiliInfo(selectedPlaybackScreen()) == null) {
+ return VpTexts.tr("label.videoplayer.quality", "Quality");
+ }
+ int quality = displayedLocalBiliQuality(currentAvailableBiliQualities());
+ return VpTexts.tr("label.videoplayer.bili_quality.local_value", "Bili: %s", biliQualityText(quality).getString());
+ }
+
+ private Component ccSubtitleButtonText(ClientVideoScreen screen) {
+ if (screen == null || !screen.subtitles().hasSelectedTrack()) {
+ return VpTexts.tr("label.videoplayer.cc_subtitle.off_value", "CC: Off");
+ }
+ return VpTexts.tr("label.videoplayer.cc_subtitle.value", "CC: %s", screen.subtitles().selectedLabel());
+ }
+
+ private Component ccSubtitleOptionText(ClientSubtitleController.Option option) {
+ String label = option == null ? "" : option.label();
+ if (label == null || label.isBlank()) label = option == null ? "" : option.language();
+ return Component.literal(label == null || label.isBlank() ? "CC" : label);
+ }
+
+ private String ccSubtitleOverlaySignature() {
+ if (!ccSubtitleOverlayOpen) return "";
+ ClientVideoScreen screen = selectedPlaybackScreen();
+ if (screen == null) return "none";
+ ClientSubtitleController subtitles = screen.subtitles();
+ StringBuilder builder = new StringBuilder();
+ builder.append(subtitles.catalogLoaded()).append('|').append(subtitles.selectedKey());
+ for (ClientSubtitleController.Option option : subtitles.options()) {
+ builder.append('|').append(option.key()).append('=').append(option.label());
+ }
+ return builder.toString();
+ }
+
+ private Component screenBiliQualityButtonText(ClientVideoScreen screen) {
+ int quality = screen == null ? BiliQuality.UNLIMITED : BiliQuality.normalizeScreenLimit(screen.metadata.getInt(ScreenMetadata.KEY_BILIBILI_QUALITY, BiliQuality.UNLIMITED));
+ return VpTexts.tr("label.videoplayer.bili_quality.screen_value", "Bili Limit: %s", biliQualityText(quality).getString());
+ }
+
+ private Component screenYouTubeQualityButtonText(ClientVideoScreen screen) {
+ int quality = screen == null ? YouTubeQuality.AUTO : YouTubeQuality.normalizeScreenLimit(
+ screen.metadata.getInt(ScreenMetadata.KEY_YOUTUBE_QUALITY, YouTubeQuality.AUTO)
+ );
+ return VpTexts.tr("label.videoplayer.youtube_quality.screen_value", "YouTube Limit: %s", youtubeQualityText(quality).getString());
+ }
+
+ private Component biliQualityText(int quality) {
+ return VpTexts.tr(BiliQuality.translationKey(quality), BiliQuality.fallbackLabel(quality));
+ }
+
+ private Component youtubeQualityText(int quality) {
+ return VpTexts.tr(YouTubeQuality.translationKey(quality), YouTubeQuality.fallbackLabel(quality));
+ }
+
+ private List currentAvailableBiliQualities() {
+ VideoInfo info = currentBiliInfo(selectedPlaybackScreen());
+ if (info == null) return List.of();
+ return BiliBiliVideoProvider.availableQualities(info.rawPath());
+ }
+
+ private List currentAvailableYouTubeQualities() {
+ VideoInfo info = currentYouTubeInfo(selectedPlaybackScreen());
+ if (info == null) return List.of();
+ return YouTubeProvider.availableQualities(info.rawPath());
+ }
+
+ private int displayedLocalBiliQuality(List available) {
+ int configured = VideoPlayerClient.config == null ? BiliQuality.DEFAULT_QN : BiliQuality.normalizeClient(VideoPlayerClient.config.bilibiliQuality);
+ if (available == null || available.isEmpty() || available.contains(configured)) return configured;
+ return BiliQuality.bestAtOrBelow(available, configured);
+ }
+
+ private int displayedLocalYouTubeQuality(List available) {
+ int configured = VideoPlayerClient.config == null
+ ? YouTubeQuality.AUTO
+ : YouTubeQuality.normalizeClient(VideoPlayerClient.config.youtubeQuality);
+ if (configured == YouTubeQuality.AUTO || available == null || available.isEmpty() || available.contains(configured)) {
+ return configured;
+ }
+ return YouTubeQuality.bestAtOrBelow(available, configured);
+ }
+
+ private VideoInfo currentBiliInfo(ClientVideoScreen screen) {
+ if (screen == null) return null;
+ VideoInfo info = screen.currentPlaybackInfo();
+ if (info == null || info.rawPath() == null || info.rawPath().isBlank()) return null;
+ return BiliBiliVideoProvider.isBiliVideoRawPath(info.rawPath()) ? info : null;
+ }
+
+ private VideoInfo currentYouTubeInfo(ClientVideoScreen screen) {
+ if (screen == null) return null;
+ VideoInfo info = screen.currentPlaybackInfo();
+ if (info == null || info.rawPath() == null || info.rawPath().isBlank()) return null;
+ return YouTubeProvider.isYouTubeRawPath(info.rawPath()) ? info : null;
+ }
+
+ private void selectLocalBiliQuality(int quality) {
+ if (!currentAvailableBiliQualities().contains(quality)) return;
+ ClientVideoScreen screen = selectedPlaybackScreen();
+ VideoInfo info = currentBiliInfo(screen);
+ if (screen == null || info == null) return;
+ VideoPlayerClient.config.bilibiliQuality = BiliQuality.normalizeClient(quality);
+ VideoPlayerClient.saveConfig();
+ biliLocalQualityOverlayOpen = false;
+ rebuildWidgets();
+ ClientPacketHandler.reloadQualityPlayback(screen);
+ }
+
+ private void selectLocalYouTubeQuality(int quality) {
+ List available = currentAvailableYouTubeQualities();
+ if (quality != YouTubeQuality.AUTO && !available.contains(quality)) return;
+ ClientVideoScreen screen = selectedPlaybackScreen();
+ VideoInfo info = currentYouTubeInfo(screen);
+ if (screen == null || info == null) return;
+ VideoPlayerClient.config.youtubeQuality = YouTubeQuality.normalizeClient(quality);
+ VideoPlayerClient.saveConfig();
+ biliLocalQualityOverlayOpen = false;
+ rebuildWidgets();
+ ClientPacketHandler.reloadQualityPlayback(screen);
+ }
+
+ private void selectScreenBiliQuality(int quality) {
+ ClientVideoScreen screen = selectedScreen();
+ if (screen == null) return;
+ biliScreenQualityOverlayOpen = false;
+ youtubeScreenQualityOverlay = false;
+ setMetadata(screen, ScreenMetadata.KEY_BILIBILI_QUALITY, MetaValue.ofInt(BiliQuality.normalizeScreenLimit(quality)));
+ }
+
+ private void selectScreenYouTubeQuality(int quality) {
+ ClientVideoScreen screen = selectedScreen();
+ if (screen == null) return;
+ biliScreenQualityOverlayOpen = false;
+ youtubeScreenQualityOverlay = false;
+ setMetadata(screen, ScreenMetadata.KEY_YOUTUBE_QUALITY, MetaValue.ofInt(YouTubeQuality.normalizeScreenLimit(quality)));
+ }
+
+ private Consumer permissionFeedback(VpButtonWidget button) {
+ return result -> {
+ if (ClientPacketHandler.denied(result)) button.showPermissionDenied();
+ };
+ }
+
+ private void closeOnOk(VpButtonWidget button, ClientPacketHandler.RequestResult result) {
+ if (ClientPacketHandler.denied(result)) {
+ button.showPermissionDenied();
+ return;
+ }
+ if (result != null && result.status() == RequestResultStatus.OK) {
+ onClose();
+ }
+ }
+
+ private boolean canGlobal(VideoPermissionAction action) {
+ return ClientPermissionCache.allowedOrUnknown(action, "", "");
+ }
+
+ private boolean canArea(VideoPermissionAction action, ClientVideoArea area) {
+ return area == null || ClientPermissionCache.allowedOrUnknown(action, area.name, "");
+ }
+
+ private boolean canScreen(VideoPermissionAction action, ClientVideoScreen screen) {
+ return screen == null || ClientPermissionCache.allowedOrUnknown(action, screen);
+ }
+
+ private VpSliderWidget slider(String label, int x, int y, int width, int value, IntConsumer action) {
+ return slider(label, x, y, width, value, action, ignored -> VideoPlayerClient.saveConfig());
+ }
+
+ private VpSliderWidget slider(Component label, int x, int y, int width, int value, IntConsumer action) {
+ return slider(label, x, y, width, value, action, ignored -> VideoPlayerClient.saveConfig());
+ }
+
+ private VpSliderWidget slider(String label, int x, int y, int width, int value, IntConsumer action, IntConsumer commit) {
+ VpSliderWidget slider = new VpSliderWidget(x, y, Math.max(60, width), CONTROL_HEIGHT, label, value, action, commit, THEME);
+ addRenderableWidget(slider);
+ registerDrawable(slider, y, CONTROL_HEIGHT);
+ return slider;
+ }
+
+ private VpSliderWidget slider(Component label, int x, int y, int width, int value, IntConsumer action, IntConsumer commit) {
+ VpSliderWidget slider = new VpSliderWidget(x, y, Math.max(60, width), CONTROL_HEIGHT, label, value, action, commit, THEME);
+ addRenderableWidget(slider);
+ registerDrawable(slider, y, CONTROL_HEIGHT);
+ return slider;
+ }
+
+ private VpSliderWidget slider(String label, int x, int y, int width, int value, IntConsumer action, IntConsumer commit, IntFunction messageFormatter) {
+ VpSliderWidget slider = new VpSliderWidget(x, y, Math.max(60, width), CONTROL_HEIGHT, label, value, action, commit,
+ (VpSliderWidget.TextFormatter) v -> Component.literal(messageFormatter.apply(v)), THEME);
+ addRenderableWidget(slider);
+ registerDrawable(slider, y, CONTROL_HEIGHT);
+ return slider;
+ }
+
+ private VpProgressSliderWidget progressSlider(int x, int y, int width,
+ java.util.function.Supplier source,
+ java.util.function.LongConsumer preview,
+ java.util.function.LongConsumer commit,
+ Runnable dragStart,
+ Runnable dragEnd) {
+ VpProgressSliderWidget slider = new VpProgressSliderWidget(x, y, Math.max(80, width), PLAYBACK_PROGRESS_HEIGHT, source, preview, commit, dragStart, dragEnd, THEME);
+ addRenderableWidget(slider);
+ registerDrawable(slider, y, PLAYBACK_PROGRESS_HEIGHT);
+ return slider;
+ }
+
+ private void registerDrawable(Renderable drawable, int y, int height) {
+ switch (widgetGroup) {
+ case FIXED -> fixedDrawables.add(drawable);
+ case AREA_SCROLL -> {
+ applyClip(drawable, WidgetGroup.AREA_SCROLL);
+ areaScrollDrawables.add(drawable);
+ trackGroupBottom(WidgetGroup.AREA_SCROLL, y + height);
+ }
+ case SCREEN_SCROLL -> {
+ applyClip(drawable, WidgetGroup.SCREEN_SCROLL);
+ screenScrollDrawables.add(drawable);
+ trackGroupBottom(WidgetGroup.SCREEN_SCROLL, y + height);
+ }
+ case CONTENT_SCROLL -> {
+ applyClip(drawable, WidgetGroup.CONTENT_SCROLL);
+ contentScrollDrawables.add(drawable);
+ trackGroupBottom(WidgetGroup.CONTENT_SCROLL, y + height);
+ }
+ }
+ }
+
+ private void applyClip(Renderable drawable, WidgetGroup group) {
+ int left = clipLeft(group);
+ int top = clipTop(group);
+ int right = clipRight(group);
+ int bottom = clipBottom(group);
+ if (drawable instanceof VpButtonWidget button) {
+ button.clip(left, top, right, bottom);
+ } else if (drawable instanceof VpTextFieldWidget field) {
+ field.clip(left, top, right, bottom);
+ } else if (drawable instanceof VpSliderWidget slider) {
+ slider.clip(left, top, right, bottom);
+ }
+ }
+
+ private void trackGroupBottom(WidgetGroup group, int visualBottom) {
+ int contentBottom = visualBottom + scrollForGroup(group) - clipTop(group);
+ switch (group) {
+ case AREA_SCROLL -> areaScrollContentHeight = Math.max(areaScrollContentHeight, contentBottom);
+ case SCREEN_SCROLL -> screenScrollContentHeight = Math.max(screenScrollContentHeight, contentBottom);
+ case CONTENT_SCROLL -> contentScrollContentHeight = Math.max(contentScrollContentHeight, contentBottom);
+ case FIXED -> {
+ }
+ }
+ }
+
+ private void trackContentBottom(int visualBottom) {
+ trackGroupBottom(WidgetGroup.CONTENT_SCROLL, visualBottom);
+ }
+
+ private int scrollForGroup(WidgetGroup group) {
+ return switch (group) {
+ case AREA_SCROLL -> areaScroll;
+ case SCREEN_SCROLL -> screenScroll;
+ case CONTENT_SCROLL -> contentScroll;
+ case FIXED -> 0;
+ };
+ }
+
+ private int clipLeft(WidgetGroup group) {
+ return switch (group) {
+ case AREA_SCROLL, SCREEN_SCROLL -> sidebarX();
+ case CONTENT_SCROLL -> mainX();
+ case FIXED -> 0;
+ };
+ }
+
+ private int clipTop(WidgetGroup group) {
+ return switch (group) {
+ case AREA_SCROLL -> sidebarAreaViewportTop();
+ case SCREEN_SCROLL -> sidebarScreenViewportTop();
+ case CONTENT_SCROLL -> contentViewportTop();
+ case FIXED -> 0;
+ };
+ }
+
+ private int clipRight(WidgetGroup group) {
+ return switch (group) {
+ case AREA_SCROLL, SCREEN_SCROLL -> sidebarX() + SIDEBAR_WIDTH;
+ case CONTENT_SCROLL -> mainX() + mainW();
+ case FIXED -> width;
+ };
+ }
+
+ private int clipBottom(WidgetGroup group) {
+ return switch (group) {
+ case AREA_SCROLL -> sidebarAreaViewportBottom();
+ case SCREEN_SCROLL -> sidebarScreenViewportBottom();
+ case CONTENT_SCROLL -> contentViewportBottom();
+ case FIXED -> height;
+ };
+ }
+
+ private void preserveCurrentFieldsForReopen() {
+ if (tab == Tab.CREATE_EDIT) {
+ copyCreateEditFieldsToDraft();
+ }
+ }
+
+ private int actionButtonWidth(int width, int count) {
+ return Math.max(64, (width - GAP * (count - 1)) / count);
+ }
+
+ private void clearSphereFields() {
+ sphereCenterXField = null;
+ sphereCenterYField = null;
+ sphereCenterZField = null;
+ sphereRadiusField = null;
+ sphereLatField = null;
+ sphereLonField = null;
+ sphereRotXField = null;
+ sphereRotYField = null;
+ sphereRotZField = null;
+ }
+
+ private void copyCreateEditFieldsToDraft() {
+ VideoCreationEditor.Draft draft = editor.draft();
+ if (nameField != null && draft.operation != VideoCreationEditor.Operation.EDIT_SCREEN_GEOMETRY) {
+ draft.name = nameField.getValue().trim();
+ }
+ if (draft.operation == VideoCreationEditor.Operation.EDIT_SCREEN_GEOMETRY) {
+ draft.name = selectedScreenName == null ? "" : selectedScreenName;
+ }
+ draft.areaName = selectedAreaName == null ? "" : selectedAreaName;
+ if (sourceField != null) draft.source = sourceField.getValue().trim();
+ Float centerX = parseFloat(sphereCenterXField);
+ Float centerY = parseFloat(sphereCenterYField);
+ Float centerZ = parseFloat(sphereCenterZField);
+ Float sphereRadius = parseFloat(sphereRadiusField);
+ Integer sphereLat = parseInt(sphereLatField);
+ Integer sphereLon = parseInt(sphereLonField);
+ Float sphereRotX = parseFloat(sphereRotXField);
+ Float sphereRotY = parseFloat(sphereRotYField);
+ Float sphereRotZ = parseFloat(sphereRotZField);
+ if (centerX != null && centerY != null && centerZ != null) {
+ draft.sphereCenter = new Vector3f(centerX, centerY, centerZ);
+ }
+ if (sphereRadius != null) draft.sphereRadius = sphereRadius;
+ if (sphereLat != null) draft.sphereLat = VideoScreen.clampSphereSegments(sphereLat);
+ if (sphereLon != null) draft.sphereLon = VideoScreen.clampSphereSegments(sphereLon);
+ if (sphereRotX != null) draft.sphereRotX = sphereRotX;
+ if (sphereRotY != null) draft.sphereRotY = sphereRotY;
+ if (sphereRotZ != null) draft.sphereRotZ = sphereRotZ;
+ draft.target = draft.operation.target();
+ }
+
+ private Component selectionButtonText() {
+ return editor.selecting()
+ ? VpTexts.tr("button.videoplayer.cancel_selection", "Cancel Selection")
+ : VpTexts.tr("button.videoplayer.start_selection", "Start Selection");
+ }
+
+ private void toggleSelection() {
+ if (editor.selecting()) {
+ editor.clearSelection();
+ reopen(null);
+ return;
+ }
+ copyCreateEditFieldsToDraft();
+ editor.beginSelection(editor.draft());
+ }
+
+ private void syncDraftFromSelection(boolean includeScreenSource) {
+ syncDraftFromSelection(includeScreenSource, true);
+ }
+
+ private void syncDraftFromSelection(boolean includeScreenSource, boolean includeScreenDisplay) {
+ VideoCreationEditor.Draft draft = editor.draft();
+ if (draft.operation == null) draft.operation = VideoCreationEditor.Operation.CREATE_AREA;
+ draft.areaName = selectedAreaName == null ? "" : selectedAreaName;
+ if (draft.operation == VideoCreationEditor.Operation.EDIT_SCREEN_GEOMETRY) {
+ draft.name = selectedScreenName == null ? "" : selectedScreenName;
+ }
+ if (includeScreenSource) {
+ ClientVideoScreen screen = selectedScreen();
+ if (screen != null) draft.source = safe(screen.source);
+ }
+ ClientVideoScreen screen = selectedScreen();
+ if (includeScreenDisplay && draft.operation == VideoCreationEditor.Operation.EDIT_SCREEN_GEOMETRY && screen != null) {
+ copyScreenDisplayToDraft(screen, draft);
+ }
+ draft.target = draft.operation.target();
+ }
+
+ private boolean preserveDraftForCurrentSelection() {
+ VideoCreationEditor.Draft draft = editor.draft();
+ return draft.operation == VideoCreationEditor.Operation.EDIT_SCREEN_GEOMETRY
+ && Objects.equals(safe(draft.areaName), safe(selectedAreaName))
+ && Objects.equals(safe(draft.name), safe(selectedScreenName));
+ }
+
+ private void copyScreenDisplayToDraft(ClientVideoScreen screen, VideoCreationEditor.Draft draft) {
+ draft.surface = screen.surface == null ? ScreenSurface.FLAT : screen.surface;
+ draft.stereo3d = screen.stereo3d;
+ draft.spherePreset = screen.spherePreset;
+ draft.sphereCenter = screen.spherePreset && screen.sphereCenter != null ? new Vector3f(screen.sphereCenter) : null;
+ draft.sphereRadius = screen.sphereRadius;
+ draft.sphereLat = screen.sphereLat;
+ draft.sphereLon = screen.sphereLon;
+ draft.sphereRotX = screen.sphereRotX;
+ draft.sphereRotY = screen.sphereRotY;
+ draft.sphereRotZ = screen.sphereRotZ;
+ draft.sphereSkybox = screen.sphereSkybox;
+ }
+
+ private void ensureSpherePresetDefaults(VideoCreationEditor.Draft draft) {
+ draft.spherePreset = true;
+ if (draft.sphereCenter == null) draft.sphereCenter = defaultSphereCenter();
+ if (!Float.isFinite(draft.sphereRadius) || draft.sphereRadius <= 0) draft.sphereRadius = 10;
+ draft.sphereLat = VideoScreen.clampSphereSegments(draft.sphereLat);
+ draft.sphereLon = VideoScreen.clampSphereSegments(draft.sphereLon);
+ }
+
+ private Vector3f defaultSphereCenter() {
+ ClientVideoScreen screen = selectedScreen();
+ if (screen != null && screen.spherePreset && screen.sphereCenter != null) {
+ return new Vector3f(screen.sphereCenter);
+ }
+ ClientVideoArea area = selectedArea();
+ if (area != null) {
+ return new Vector3f(
+ (area.min.x + area.max.x) * 0.5f,
+ (area.min.y + area.max.y) * 0.5f,
+ (area.min.z + area.max.z) * 0.5f
+ );
+ }
+ return new Vector3f();
+ }
+
+ private void cycleSource() {
+ List sources = sourceNames();
+ if (sources.isEmpty()) {
+ if (sourceField != null) sourceField.setValue("");
+ return;
+ }
+ String current = sourceField == null ? "" : sourceField.getValue().trim();
+ int index = sources.indexOf(current);
+ String next = sources.get((index + 1 + sources.size()) % sources.size());
+ if (sourceField != null) sourceField.setValue(next);
+ editor.draft().source = next;
+ }
+
+ private boolean canSaveScreenConfig(ClientVideoScreen screen, VideoCreationEditor.Draft draft) {
+ if (screen == null) return false;
+ return draft.surface == ScreenSurface.SPHERE_360 ? draft.spherePreset : screen.vertices.size() >= 3;
+ }
+
+ private boolean canSelectForDraft(VideoCreationEditor.Draft draft, ClientVideoArea area, ClientVideoScreen screen) {
+ return switch (draft.operation) {
+ case CREATE_AREA -> true;
+ case CREATE_SCREEN -> area != null;
+ case EDIT_SCREEN_GEOMETRY -> screen != null && canScreen(VideoPermissionAction.UPDATE_SCREEN, screen);
+ };
+ }
+
+ private boolean canSubmitDraft(VideoCreationEditor.Draft draft, ClientVideoArea area, ClientVideoScreen screen) {
+ return canSelectForDraft(draft, area, screen);
+ }
+
+ private void saveScreenConfig(VpButtonWidget button) {
+ ClientVideoScreen screen = selectedScreen();
+ if (screen == null) return;
+ copyCreateEditFieldsToDraft();
+ VideoCreationEditor.Draft draft = editor.draft();
+ String source = sourceField == null ? "" : sourceField.getValue().trim();
+ if (draft.surface == ScreenSurface.SPHERE_360 && !draft.spherePreset) {
+ sendLocalError(VpTexts.tr("error.videoplayer.sphere_preset_required", "Define 360 parameters first"));
+ return;
+ }
+ List vertices = copyVertices(screen.vertices);
+ VideoScreen displayConfig = new VideoScreen(screen.area, screen.name, vertices, source);
+ editor.applyDraftDisplay(displayConfig);
+ ClientPacketHandler.updateScreen(screen, vertices, source, displayConfig, permissionFeedback(button));
+ editor.draft().source = source;
+ }
+
+ private void togglePlaybackStereo(VpButtonWidget button) {
+ ClientVideoScreen screen = selectedScreen();
+ if (screen == null) return;
+ VideoScreen displayConfig = displayConfigFromScreen(screen);
+ displayConfig.stereo3d = !screen.stereo3d;
+ sendDisplayConfig(screen, displayConfig, permissionFeedback(button));
+ }
+
+ private void togglePlaybackSurface(VpButtonWidget button) {
+ ClientVideoScreen screen = selectedScreen();
+ if (screen == null) return;
+ VideoScreen displayConfig = displayConfigFromScreen(screen);
+ if (screen.surface == ScreenSurface.SPHERE_360) {
+ if (screen.vertices.size() < 3) {
+ sendLocalError(VpTexts.tr("error.videoplayer.no_flat_vertices", "Current Screen has no available flat vertices"));
+ return;
+ }
+ displayConfig.surface = ScreenSurface.FLAT;
+ } else {
+ if (!screen.spherePreset) {
+ sendLocalError(VpTexts.tr("error.videoplayer.define_sphere_in_editor", "Define 360 parameters in the create/edit page first"));
+ return;
+ }
+ displayConfig.surface = ScreenSurface.SPHERE_360;
+ }
+ sendDisplayConfig(screen, displayConfig, permissionFeedback(button));
+ }
+
+ private VideoScreen displayConfigFromScreen(ClientVideoScreen screen) {
+ VideoScreen displayConfig = new VideoScreen(screen.area, screen.name, copyVertices(screen.vertices), safe(screen.source));
+ displayConfig.copyDisplayConfigFrom(screen);
+ return displayConfig;
+ }
+
+ private void sendDisplayConfig(ClientVideoScreen screen, VideoScreen displayConfig) {
+ sendDisplayConfig(screen, displayConfig, null);
+ }
+
+ private void sendDisplayConfig(ClientVideoScreen screen, VideoScreen displayConfig, Consumer callback) {
+ ClientPacketHandler.updateScreen(screen, copyVertices(screen.vertices), safe(screen.source), displayConfig, callback);
+ }
+
+ private void sendLocalError(Component message) {
+ if (minecraft != null && minecraft.player != null) {
+ minecraft.player.sendSystemMessage(message.copy().withStyle(ChatFormatting.RED));
+ }
+ }
+
+ private void setScreenScaleX(float scaleX) {
+ ClientVideoScreen screen = selectedScreen();
+ if (screen == null) return;
+ setScaleAndRefresh(screen, false, scaleX, screen.scaleY);
+ }
+
+ private void setScreenScaleY(float scaleY) {
+ ClientVideoScreen screen = selectedScreen();
+ if (screen == null) return;
+ setScaleAndRefresh(screen, false, screen.scaleX, scaleY);
+ }
+
+ private int scaleToSliderValue(float scale) {
+ float clamped = Math.clamp(scale, MIN_SCREEN_SCALE, MAX_SCREEN_SCALE);
+ double logMin = log2(MIN_SCREEN_SCALE);
+ double range = log2(MAX_SCREEN_SCALE) - logMin;
+ return Math.clamp((int) Math.round(((log2(clamped) - logMin) / range) * 100.0), 0, 100);
+ }
+
+ private float sliderValueToScale(int value) {
+ double logMin = log2(MIN_SCREEN_SCALE);
+ double range = log2(MAX_SCREEN_SCALE) - logMin;
+ return (float) Math.pow(2.0, logMin + range * Math.clamp(value, 0, 100) / 100.0);
+ }
+
+ private double log2(float value) {
+ return Math.log(value) / Math.log(2.0);
+ }
+
+ private void setScaleAndRefresh(ClientVideoScreen screen, boolean fill, float scaleX, float scaleY) {
+ setScaleAndRefresh(screen, fill, scaleX, scaleY, null);
+ }
+
+ private void setScaleAndRefresh(ClientVideoScreen screen, boolean fill, float scaleX, float scaleY, Consumer callback) {
+ if (screen == null || scaleX < MIN_SCREEN_SCALE || scaleX > MAX_SCREEN_SCALE || scaleY < MIN_SCREEN_SCALE || scaleY > MAX_SCREEN_SCALE) {
+ return;
+ }
+ ClientPacketHandler.setScale(screen, fill, scaleX, scaleY, callback);
+ reopen(null);
+ }
+
+ private void setDefaultVolume(int volume) {
+ ClientVideoScreen screen = selectedScreen();
+ if (screen == null) return;
+ ScreenVolumeCache.invalidate(screen);
+ setMetadata(screen, ScreenMetadata.KEY_DEFAULT_VOLUME, MetaValue.ofInt(Math.clamp(volume, 0, 100)));
+ }
+
+ private void toggleMeta(VpButtonWidget button, String key, boolean defaultValue) {
+ ClientVideoScreen screen = selectedScreen();
+ if (screen == null) return;
+ boolean value = !screen.metadata.getBool(key, defaultValue);
+ setMetadata(screen, key, MetaValue.ofBool(value), permissionFeedback(button));
+ }
+
+ private void setCustomMeta(VpButtonWidget button, boolean remove) {
+ ClientVideoScreen screen = selectedScreen();
+ if (screen == null || customKeyField == null) return;
+ String key = customKeyField.getValue().trim();
+ if (key.isEmpty()) return;
+ if (remove) {
+ removeMetadata(screen, key, permissionFeedback(button));
+ return;
+ }
+ try {
+ MetaValue value = MetaValue.parse(customMetaType, customValueField == null ? "" : customValueField.getValue());
+ setMetadata(screen, key, value, permissionFeedback(button));
+ } catch (Exception ignored) {
+ }
+ }
+
+ private void setMetadata(ClientVideoScreen screen, String key, MetaValue value) {
+ setMetadata(screen, key, value, null);
+ }
+
+ private void setMetadata(ClientVideoScreen screen, String key, MetaValue value, Consumer callback) {
+ if (isReservedMetaKey(key)) return;
+ try {
+ ClientPacketHandler.setMetadata(screen, key, value, result -> {
+ if (result != null && result.status() == RequestResultStatus.OK
+ && VideoPlayerClient.screens.contains(screen)
+ && minecraft.gui.screen() instanceof VideoManagementScreen) {
+ reopen(null);
+ }
+ if (callback != null) callback.accept(result);
+ });
+ } catch (Exception ignored) {
+ }
+ }
+
+ private void removeMetadata(ClientVideoScreen screen, String key) {
+ removeMetadata(screen, key, null);
+ }
+
+ private void removeMetadata(ClientVideoScreen screen, String key, Consumer callback) {
+ if (isReservedMetaKey(key)) return;
+ try {
+ ClientPacketHandler.removeMetadata(screen, key, result -> {
+ if (result != null && result.status() == RequestResultStatus.OK
+ && VideoPlayerClient.screens.contains(screen)
+ && minecraft.gui.screen() instanceof VideoManagementScreen) {
+ reopen(null);
+ }
+ if (callback != null) callback.accept(result);
+ });
+ } catch (Exception ignored) {
+ }
+ }
+
+ private boolean isReservedMetaKey(String key) {
+ return switch (key) {
+ case "3d", "360", "spherePreset", "skybox", "x", "y", "z", "radius", "lat", "lon", "rot", "rotX", "rotY", "rotZ", "aspect", "fov" -> true;
+ default -> false;
+ };
+ }
+
+ private void cycleCustomMetaType() {
+ MetaType[] values = MetaType.values();
+ customMetaType = values[(customMetaType.ordinal() + 1) % values.length];
+ reopen(null);
+ }
+
+ private void deleteSelectedArea(VpButtonWidget button) {
+ ClientVideoArea area = selectedArea();
+ if (area == null) return;
+ if (!confirmDeleteArea) {
+ confirmDeleteArea = true;
+ confirmDeleteScreen = false;
+ reopen(null);
+ return;
+ }
+ ClientPacketHandler.removeArea(area.name, result -> {
+ if (ClientPacketHandler.denied(result)) {
+ button.showPermissionDenied();
+ return;
+ }
+ if (result != null && result.status() == RequestResultStatus.OK) {
+ selectedAreaName = null;
+ selectedScreenName = null;
+ confirmDeleteArea = false;
+ reopen(null);
+ }
+ });
+ }
+
+ private void deleteSelectedScreen(VpButtonWidget button) {
+ ClientVideoScreen screen = selectedScreen();
+ if (screen == null) return;
+ if (!confirmDeleteScreen) {
+ confirmDeleteScreen = true;
+ confirmDeleteArea = false;
+ reopen(null);
+ return;
+ }
+ ClientPacketHandler.removeScreen(screen, result -> {
+ if (ClientPacketHandler.denied(result)) {
+ button.showPermissionDenied();
+ return;
+ }
+ if (result != null && result.status() == RequestResultStatus.OK) {
+ selectedScreenName = null;
+ confirmDeleteScreen = false;
+ reopen(null);
+ }
+ });
+ }
+
+ private void ensureSelection() {
+ if (selectedAreaName == null || !VideoPlayerClient.areas.containsKey(selectedAreaName)) {
+ selectedAreaName = firstAreaName();
+ }
+ ClientVideoArea area = selectedArea();
+ if (area == null) {
+ selectedScreenName = null;
+ return;
+ }
+ if (selectedScreenName == null || area.getScreen(selectedScreenName) == null) {
+ selectedScreenName = firstScreenName(selectedAreaName);
+ }
+ }
+
+ private ClientVideoArea selectedArea() {
+ if (selectedAreaName == null) return null;
+ return VideoPlayerClient.areas.get(selectedAreaName);
+ }
+
+ private ClientVideoScreen selectedScreen() {
+ ClientVideoArea area = selectedArea();
+ if (area == null || selectedScreenName == null) return null;
+ return area.getScreen(selectedScreenName);
+ }
+
+ private ClientVideoScreen selectedPlaybackScreen() {
+ ClientVideoScreen screen = selectedScreen();
+ return screen == null ? null : screen.getScreen();
+ }
+
+ private boolean usesMpvPlaybackVolume(ClientVideoScreen playbackScreen) {
+ if (playbackScreen != null && playbackScreen.player instanceof VideoPlayer player) {
+ return VideoBackends.MPV.equals(player.backendName());
+ }
+ return VideoBackends.MPV.equals(VideoBackends.normalize(VideoPlayerClient.config.videoBackend)) && MpvVideoBackend.isAvailable();
+ }
+
+ private List areaNames() {
+ return VideoPlayerClient.areas.values().stream()
+ .map(area -> area.name)
+ .sorted()
+ .toList();
+ }
+
+ private String areaSignature() {
+ return areaNames().toString();
+ }
+
+ private List screensForSelectedArea() {
+ ClientVideoArea area = selectedArea();
+ if (area == null) return List.of();
+ return area.screens.stream()
+ .map(screen -> (ClientVideoScreen) screen)
+ .sorted(Comparator.comparing(screen -> screen.name))
+ .toList();
+ }
+
+ private String screenSignature() {
+ return screensForSelectedArea().stream()
+ .map(screen -> screen.name + ":" + safe(screen.source) + ":" + screen.fill + ":" + format(screen.scaleX) + ":" + format(screen.scaleY)
+ + ":" + screen.surface + ":" + screen.stereo3d + ":" + screen.spherePreset + ":" + format(screen.sphereRadius)
+ + ":" + screen.sphereLat + ":" + screen.sphereLon + ":" + format(screen.sphereRotX) + ":" + format(screen.sphereRotY) + ":" + format(screen.sphereRotZ) + ":" + screen.sphereSkybox)
+ .toList()
+ .toString();
+ }
+
+ private String metadataSignature() {
+ ClientVideoScreen screen = selectedScreen();
+ if (screen == null) return "";
+ return screen.metadata.entries().entrySet().stream()
+ .sorted(Map.Entry.comparingByKey())
+ .map(entry -> entry.getKey() + ":" + entry.getValue().type + "=" + entry.getValue().toDisplayString())
+ .toList()
+ .toString();
+ }
+
+ private List sourceNames() {
+ ClientVideoArea area = selectedArea();
+ if (area == null) return List.of();
+ String self = editor.draft().operation == VideoCreationEditor.Operation.EDIT_SCREEN_GEOMETRY ? selectedScreenName : nameField == null ? "" : nameField.getValue().trim();
+ ArrayList result = new ArrayList<>();
+ result.add("");
+ area.screens.stream()
+ .filter(screen -> (screen.source == null || screen.source.isEmpty()) && !screen.name.equals(self))
+ .map(screen -> screen.name)
+ .sorted()
+ .forEach(result::add);
+ return result;
+ }
+
+ private String firstAreaName() {
+ return areaNames().stream().findFirst().orElse("");
+ }
+
+ private String firstScreenName(String areaName) {
+ ClientVideoArea area = VideoPlayerClient.areas.get(areaName);
+ if (area == null) return "";
+ return area.screens.stream()
+ .map(screen -> screen.name)
+ .sorted()
+ .findFirst()
+ .orElse("");
+ }
+
+ private void reopen(ClientVideoScreen focusedScreen) {
+ reopen(focusedScreen, false);
+ }
+
+ private void reopenPreservingDraft() {
+ reopen(null, true);
+ }
+
+ private void reopen(ClientVideoScreen focusedScreen, boolean preserveDraftDisplay) {
+ if (diagnosticsReview != null) diagnosticsReview.beginHandoff();
+ if (focusedScreen != null) {
+ minecraft.gui.setScreen(new VideoManagementScreen(editor, focusedScreen, tab,
+ danmakuOverlayOpen, biliLocalQualityOverlayOpen, biliScreenQualityOverlayOpen,
+ youtubeScreenQualityOverlay, ccSubtitleOverlayOpen,
+ playbackPreviewPinned, diagnosticsReview));
+ return;
+ }
+ minecraft.gui.setScreen(new VideoManagementScreen(
+ editor,
+ tab,
+ selectedAreaName,
+ selectedScreenName,
+ areaScroll,
+ screenScroll,
+ contentScroll,
+ confirmDeleteArea,
+ confirmDeleteScreen,
+ customMetaType,
+ preserveDraftDisplay,
+ danmakuOverlayOpen,
+ biliLocalQualityOverlayOpen,
+ biliScreenQualityOverlayOpen,
+ youtubeScreenQualityOverlay,
+ ccSubtitleOverlayOpen,
+ playbackPreviewPinned,
+ diagnosticsReview
+ ));
+ }
+
+ private Component operationLabel(VideoCreationEditor.Operation operation) {
+ return switch (operation) {
+ case CREATE_AREA -> VpTexts.tr("button.videoplayer.create_area", "Create Area");
+ case CREATE_SCREEN -> VpTexts.tr("button.videoplayer.create_screen", "Create Screen");
+ case EDIT_SCREEN_GEOMETRY -> VpTexts.tr("button.videoplayer.edit_screen", "Edit Screen");
+ };
+ }
+
+ private Component boolLabel(ClientVideoScreen screen, String key, boolean defaultValue) {
+ boolean value = screen == null ? defaultValue : screen.metadata.getBool(key, defaultValue);
+ return onOff(value);
+ }
+
+ private Component onOff(boolean value) {
+ return value ? VpTexts.tr("label.videoplayer.on", "On") : VpTexts.tr("label.videoplayer.off", "Off");
+ }
+
+ private Component danmakuSpeedLabel(int index) {
+ int safeIndex = Math.clamp(index, 0, DANMAKU_SPEED_KEYS.length - 1);
+ return VpTexts.tr(DANMAKU_SPEED_KEYS[safeIndex], DANMAKU_SPEED_FALLBACKS[safeIndex]);
+ }
+
+ private Component danmakuDensityLabel(int index) {
+ int safeIndex = Math.clamp(index, 0, DANMAKU_DENSITY_KEYS.length - 1);
+ return VpTexts.tr(DANMAKU_DENSITY_KEYS[safeIndex], DANMAKU_DENSITY_FALLBACKS[safeIndex]);
+ }
+
+ private String defaultValueFor(MetaType type) {
+ return switch (type) {
+ case BOOL -> "false";
+ case INT -> "0";
+ case LONG -> "0";
+ case FLOAT -> "0";
+ case DOUBLE -> "0";
+ case STRING -> "";
+ case BOOL_ARRAY -> "false, true";
+ case INT_ARRAY -> "0, 1";
+ case FLOAT_ARRAY -> "0, 1";
+ case STRING_ARRAY -> "a, b";
+ };
+ }
+
+ private String safe(String value) {
+ return value == null ? "" : value;
+ }
+
+ private String format(float value) {
+ return String.format(Locale.ROOT, "%.4f", value);
+ }
+
+ private Float parseFloat(EditBox field) {
+ if (field == null) return null;
+ try {
+ float value = Float.parseFloat(field.getValue().trim());
+ return Float.isFinite(value) ? value : null;
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private Integer parseInt(EditBox field) {
+ if (field == null) return null;
+ try {
+ return Integer.parseInt(field.getValue().trim());
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private List copyVertices(List vertices) {
+ ArrayList copy = new ArrayList<>(vertices.size());
+ for (Vector3f vertex : vertices) {
+ copy.add(new Vector3f(vertex));
+ }
+ return copy;
+ }
+
+ private enum WidgetGroup {
+ FIXED,
+ AREA_SCROLL,
+ SCREEN_SCROLL,
+ CONTENT_SCROLL
+ }
+
+ private record SubtitleChoice(String key, Component label, boolean active) {
+ }
+
+ private enum Tab {
+ CREATE_EDIT("tab.videoplayer.create_edit", "Create/Edit"),
+ PLAYBACK("tab.videoplayer.playback", "Playback"),
+ SCREEN_SETTINGS("tab.videoplayer.screen_settings", "Screen Settings"),
+ DIAGNOSTICS("tab.videoplayer.diagnostics", "Diagnostics");
+
+ final String key;
+ final String fallback;
+
+ Tab(String key, String fallback) {
+ this.key = key;
+ this.fallback = fallback;
+ }
+
+ Component label() {
+ return VpTexts.tr(key, fallback);
+ }
+ }
+}
diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/VideoMappingScreen.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/VideoMappingScreen.java
new file mode 100644
index 0000000..982a198
--- /dev/null
+++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/VideoMappingScreen.java
@@ -0,0 +1,1162 @@
+package com.github.squi2rel.vp.creation;
+
+import com.github.squi2rel.vp.ClientPacketHandler;
+import com.github.squi2rel.vp.ClientPermissionCache;
+import com.github.squi2rel.vp.ScreenRenderer;
+import com.github.squi2rel.vp.VideoPlayerClient;
+import com.github.squi2rel.vp.i18n.VpInputTexts;
+import com.github.squi2rel.vp.i18n.VpTexts;
+import com.github.squi2rel.vp.permission.VideoPermissionAction;
+import com.github.squi2rel.vp.video.ClientVideoScreen;
+import com.github.squi2rel.vp.video.MetaValue;
+import com.github.squi2rel.vp.video.ScreenGeometry;
+import com.github.squi2rel.vp.video.ScreenMetadata;
+import org.joml.Vector2f;
+import org.joml.Vector3f;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.function.Consumer;
+import net.minecraft.client.gui.GuiGraphicsExtractor;
+import net.minecraft.client.gui.screens.Screen;
+import net.minecraft.client.input.MouseButtonEvent;
+import net.minecraft.network.chat.Component;
+import org.lwjgl.glfw.GLFW;
+
+public class VideoMappingScreen extends Screen implements ServerStateScreen {
+ private static final int HANDLE_SIZE = 3;
+ private static final int HANDLE_HIT_SIZE = 9;
+ private static final int EDGE_HIT_SIZE = 6;
+ private static final int TOP_HEIGHT = 38;
+ private static final int BOTTOM_HEIGHT = 34;
+ private static final int CONTROL_HEIGHT = 18;
+ private static final VpUiTheme THEME = VpUiTheme.classic();
+ private static final int OUTLINE_COLOR = THEME.accentColor();
+ private static final int PREVIEW_OUTLINE_COLOR = THEME.accentColor();
+ private static final int TRIANGLE_GUIDE_COLOR = VpUiRenderer.withAlpha(THEME.accentColor(), 0x99);
+ private static final float GUIDE_LINE_WIDTH = 1.4f;
+ private final Screen parent;
+ private final ClientVideoScreen screen;
+ private final ArrayList uvs = new ArrayList<>();
+ private int previewX;
+ private int previewY;
+ private int previewW;
+ private int previewH;
+ private int imageX;
+ private int imageY;
+ private int imageW;
+ private int imageH;
+ private int dragging = -1;
+ private int draggingEdge = -1;
+ private final HashSet selectedVertices = new HashSet<>();
+ private final ArrayList dragStartUvs = new ArrayList<>();
+ private DragMode dragMode = DragMode.NONE;
+ private double lastMouseX;
+ private double lastMouseY;
+ private double dragStartMouseX;
+ private double dragStartMouseY;
+ private double selectionStartX;
+ private double selectionStartY;
+ private double selectionEndX;
+ private double selectionEndY;
+ private float rotationStartAngle;
+ private Vector2f dragStartCenter = new Vector2f();
+ private float previewYaw = -0.45f;
+ private float previewPitch = 0.25f;
+ private float edgeStartAX;
+ private float edgeStartAY;
+ private float edgeStartBX;
+ private float edgeStartBY;
+ private boolean dirty;
+ private boolean keepAspect;
+ private boolean savePending;
+ private long editRevision;
+ private VpButtonWidget saveButton;
+ private VpButtonWidget resetButton;
+
+ public VideoMappingScreen(Screen parent, ClientVideoScreen screen) {
+ super(VpTexts.tr("screen.videoplayer.mapping_editor", "Custom Mapping Editor"));
+ this.parent = parent;
+ this.screen = screen;
+ loadUvs();
+ }
+
+ @Override
+ protected void init() {
+ int bottom = height - 30;
+ int startX = width / 2 - 166;
+ saveButton = button(VpTexts.tr("button.videoplayer.save", "Save"), startX, bottom, 72, button -> save(button));
+ resetButton = button(VpTexts.tr("button.videoplayer.reset", "Reset"), startX + 78, bottom, 72, button -> {
+ resetUvs();
+ save(button);
+ });
+ refreshSaveControls();
+ VpButtonWidget keepAspectButton = new VpButtonWidget(startX + 156, bottom, 98, CONTROL_HEIGHT, keepAspectText(), button -> {
+ this.keepAspect = !this.keepAspect;
+ button.setMessage(keepAspectText());
+ }, THEME);
+ addRenderableWidget(keepAspectButton);
+ button(VpTexts.tr("button.videoplayer.close", "Close"), startX + 260, bottom, 72, this::onClose);
+ }
+
+ @Override
+ public boolean isPauseScreen() {
+ return false;
+ }
+
+ @Override
+ public void onClose() {
+ saveIfDirty();
+ minecraft.gui.setScreen(parent);
+ }
+
+ @Override
+ public void extractRenderState(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
+ computeLayout();
+ extractBackground(context, mouseX, mouseY, delta);
+ drawChrome(context);
+ super.extractRenderState(context, mouseX, mouseY, delta);
+ drawCenteredLabel(context, title, width / 2, 18, THEME.primaryTextColor());
+ drawLabel(context, VpTexts.tr("label.videoplayer.preview", "Preview"), previewX, previewY - 14, THEME.secondaryTextColor());
+ drawLabel(context, Component.literal(screen.name), imageX, imageY - 14, THEME.secondaryTextColor());
+ Component controls = Component.translatableWithFallback(
+ "hint.videoplayer.mapping_controls",
+ "%1$s drag/select vertices; %2$s rotate; %3$s + %1$s multi-select",
+ VpInputTexts.mouseButton(GLFW.GLFW_MOUSE_BUTTON_LEFT),
+ VpInputTexts.mouseButton(GLFW.GLFW_MOUSE_BUTTON_RIGHT),
+ VpInputTexts.key(GLFW.GLFW_KEY_LEFT_CONTROL)
+ );
+ drawLabel(context, Component.literal(font.substrByWidth(controls, Math.max(1, width - 36)).getString()), 18, height - 46, THEME.secondaryTextColor());
+
+ drawFrame(context, previewX, previewY, previewW, previewH);
+ drawPreview(context);
+ context.outline(previewX - 1, previewY - 1, previewW + 2, previewH + 2, THEME.panelBorderColor());
+ drawFrame(context, imageX, imageY, imageW, imageH);
+ drawTexture(context);
+ context.outline(imageX - 1, imageY - 1, imageW + 2, imageH + 2, THEME.panelBorderColor());
+ drawPolygon(context);
+ drawSelectionBox(context);
+ drawHandles(context, mouseX, mouseY);
+ }
+
+ @Override
+ public void extractBackground(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
+ context.fill(0, 0, width, height, VpUiRenderer.withAlpha(THEME.canvasBackgroundColor(), 0xE6));
+ }
+
+ @Override
+ public boolean mouseClicked(MouseButtonEvent click, boolean doubleClick) {
+ double mouseX = click.x();
+ double mouseY = click.y();
+ int button = click.button();
+ if (button == 0) {
+ if (insidePreview(mouseX, mouseY)) {
+ lastMouseX = mouseX;
+ lastMouseY = mouseY;
+ dragMode = DragMode.PREVIEW_ROTATE;
+ return true;
+ }
+ int handle = handleAt(mouseX, mouseY);
+ if (handle >= 0 && click.buttonInfo().hasControlDownWithQuirk()) {
+ toggleSelectedVertex(handle);
+ return true;
+ }
+ if (handle >= 0) {
+ if (selectedVertices.contains(handle) && selectedVertices.size() > 1) {
+ beginSelectionDrag(mouseX, mouseY);
+ } else {
+ beginHandleDrag(handle);
+ }
+ return true;
+ }
+ if (selectedVertices.size() > 1 && insideSelectionBounds(mouseX, mouseY)) {
+ beginSelectionDrag(mouseX, mouseY);
+ return true;
+ }
+ draggingEdge = edgeAt(mouseX, mouseY);
+ if (draggingEdge >= 0) {
+ beginEdgeDrag(mouseX, mouseY);
+ return true;
+ }
+ if (insideMappedPolygon(mouseX, mouseY)) {
+ lastMouseX = mouseX;
+ lastMouseY = mouseY;
+ dragMode = DragMode.PAN;
+ return true;
+ }
+ if (insideImage(mouseX, mouseY)) {
+ beginBoxSelect(mouseX, mouseY);
+ return true;
+ }
+ }
+ if (button == 1 && insideImage(mouseX, mouseY)) {
+ beginRotate(mouseX, mouseY);
+ return true;
+ }
+ if (button == 1 && insidePreview(mouseX, mouseY)) {
+ lastMouseX = mouseX;
+ lastMouseY = mouseY;
+ dragMode = DragMode.PREVIEW_ROTATE;
+ return true;
+ }
+ return super.mouseClicked(click, doubleClick);
+ }
+
+ @Override
+ public boolean mouseDragged(MouseButtonEvent click, double deltaX, double deltaY) {
+ double mouseX = click.x();
+ double mouseY = click.y();
+ int button = click.button();
+ if (button == 0 && dragMode == DragMode.BOX_SELECT) {
+ selectionEndX = mouseX;
+ selectionEndY = mouseY;
+ return true;
+ }
+ if (button == 0 && dragMode == DragMode.SELECTION) {
+ moveSelected(mouseX, mouseY);
+ return true;
+ }
+ if (button == 0 && dragMode == DragMode.HANDLE && dragging >= 0) {
+ uvs.get(dragging).set(
+ clamp01((float) ((mouseX - imageX) / imageW)),
+ clamp01((float) ((mouseY - imageY) / imageH))
+ );
+ markDirty();
+ return true;
+ }
+ if (button == 0 && dragMode == DragMode.EDGE && draggingEdge >= 0) {
+ moveEdge(mouseX, mouseY, click.buttonInfo().hasShiftDown());
+ return true;
+ }
+ if (button == 0 && dragMode == DragMode.PAN) {
+ translate((float) (mouseX - lastMouseX) / imageW, (float) (mouseY - lastMouseY) / imageH);
+ lastMouseX = mouseX;
+ lastMouseY = mouseY;
+ return true;
+ }
+ if ((button == 0 || button == 1) && dragMode == DragMode.ROTATE) {
+ float after = angleAt(mouseX, mouseY, dragStartCenter);
+ float delta = normalizedAngle(after - rotationStartAngle);
+ if (click.buttonInfo().hasShiftDown()) {
+ float step = (float) Math.toRadians(15);
+ delta = Math.round(delta / step) * step;
+ }
+ rotateFromStart(delta);
+ lastMouseX = mouseX;
+ lastMouseY = mouseY;
+ return true;
+ }
+ if ((button == 0 || button == 1) && dragMode == DragMode.PREVIEW_ROTATE) {
+ previewYaw += (float) (mouseX - lastMouseX) * 0.012f;
+ previewPitch = Math.clamp(previewPitch + (float) (mouseY - lastMouseY) * 0.012f, -1.35f, 1.35f);
+ lastMouseX = mouseX;
+ lastMouseY = mouseY;
+ return true;
+ }
+ return super.mouseDragged(click, deltaX, deltaY);
+ }
+
+ @Override
+ public boolean mouseReleased(MouseButtonEvent click) {
+ double mouseX = click.x();
+ double mouseY = click.y();
+ int button = click.button();
+ if (button == 0 && dragMode == DragMode.BOX_SELECT) {
+ selectionEndX = mouseX;
+ selectionEndY = mouseY;
+ selectVerticesInBox();
+ dragging = -1;
+ draggingEdge = -1;
+ dragMode = DragMode.NONE;
+ return true;
+ }
+ boolean finishingRotation = (button == 0 || button == 1) && dragMode == DragMode.ROTATE;
+ if ((button == 0 && (dragMode == DragMode.HANDLE || dragMode == DragMode.EDGE || dragMode == DragMode.PAN || dragMode == DragMode.SELECTION))
+ || finishingRotation) {
+ if (finishingRotation) fitUvsInsideUnitSquare();
+ dragging = -1;
+ draggingEdge = -1;
+ dragMode = DragMode.NONE;
+ saveIfDirty();
+ return true;
+ }
+ if ((button == 0 || button == 1) && dragMode == DragMode.PREVIEW_ROTATE) {
+ dragMode = DragMode.NONE;
+ return true;
+ }
+ return super.mouseReleased(click);
+ }
+
+ @Override
+ public boolean mouseScrolled(double mouseX, double mouseY, double horizontalAmount, double verticalAmount) {
+ if (insideImage(mouseX, mouseY)) {
+ scale(verticalAmount > 0 ? 1.08f : 0.9259259f);
+ saveIfDirty();
+ return true;
+ }
+ return super.mouseScrolled(mouseX, mouseY, horizontalAmount, verticalAmount);
+ }
+
+ private Component keepAspectText() {
+ return VpTexts.tr("label.videoplayer.keep_aspect", "Keep Aspect: %s",
+ (keepAspect ? VpTexts.tr("label.videoplayer.on", "On") : VpTexts.tr("label.videoplayer.off", "Off")).getString());
+ }
+
+ private void beginRotate(double mouseX, double mouseY) {
+ lastMouseX = mouseX;
+ lastMouseY = mouseY;
+ dragStartMouseX = mouseX;
+ dragStartMouseY = mouseY;
+ captureDragStartUvs();
+ dragStartCenter = center(dragStartUvs);
+ rotationStartAngle = angleAt(mouseX, mouseY, dragStartCenter);
+ dragMode = DragMode.ROTATE;
+ }
+
+ private void beginHandleDrag(int handle) {
+ dragging = handle;
+ selectedVertices.clear();
+ selectedVertices.add(handle);
+ dragMode = DragMode.HANDLE;
+ }
+
+ private void beginSelectionDrag(double mouseX, double mouseY) {
+ dragging = -1;
+ dragStartMouseX = mouseX;
+ dragStartMouseY = mouseY;
+ captureDragStartUvs();
+ dragMode = DragMode.SELECTION;
+ }
+
+ private void beginBoxSelect(double mouseX, double mouseY) {
+ selectionStartX = mouseX;
+ selectionStartY = mouseY;
+ selectionEndX = mouseX;
+ selectionEndY = mouseY;
+ dragMode = DragMode.BOX_SELECT;
+ }
+
+ private void beginEdgeDrag(double mouseX, double mouseY) {
+ Vector2f a = uvs.get(draggingEdge);
+ Vector2f b = uvs.get((draggingEdge + 1) % uvs.size());
+ edgeStartAX = a.x;
+ edgeStartAY = a.y;
+ edgeStartBX = b.x;
+ edgeStartBY = b.y;
+ dragStartMouseX = mouseX;
+ dragStartMouseY = mouseY;
+ dragMode = DragMode.EDGE;
+ }
+
+ private void loadUvs() {
+ uvs.clear();
+ int vertexCount = screen.vertices.size();
+ float[] stored = screen.metadata.getFloatArray(ScreenMetadata.KEY_MAPPING_UVS);
+ if (stored != null && stored.length == vertexCount * 2) {
+ for (int i = 0; i < vertexCount; i++) {
+ uvs.add(new Vector2f(stored[i * 2], stored[i * 2 + 1]));
+ }
+ dirty = false;
+ return;
+ }
+ resetUvValues();
+ dirty = false;
+ }
+
+ private void resetUvs() {
+ resetUvValues();
+ dirty = true;
+ editRevision++;
+ }
+
+ private void resetUvValues() {
+ uvs.clear();
+ if (!resetFromGeometry()) {
+ int count = Math.max(1, screen.vertices.size());
+ for (int i = 0; i < count; i++) {
+ float angle = (float) (-Math.PI / 2.0 + Math.PI * 2.0 * i / count);
+ uvs.add(new Vector2f(
+ clamp01(0.5f + (float) Math.cos(angle) * 0.42f),
+ clamp01(0.5f + (float) Math.sin(angle) * 0.42f)
+ ));
+ }
+ }
+ }
+
+ private void computeLayout() {
+ int margin = 24;
+ int gap = 18;
+ int availableW = Math.max(240, width - margin * 2);
+ int availableH = Math.max(90, height - TOP_HEIGHT - BOTTOM_HEIGHT - 18);
+
+ int leftW = Math.max(140, Math.round(availableW * 0.2f));
+ int rightW = Math.max(120, availableW - leftW - gap);
+ previewX = margin;
+ previewY = TOP_HEIGHT;
+ previewW = leftW;
+ previewH = availableH;
+
+ int maxW = rightW;
+ int maxH = availableH;
+ float textureAspect = screen.displayTextureWidth() / (float) Math.max(1, screen.displayTextureHeight());
+ imageW = maxW;
+ imageH = Math.round(imageW / textureAspect);
+ if (imageH > maxH) {
+ imageH = maxH;
+ imageW = Math.round(imageH * textureAspect);
+ }
+ int rightX = previewX + previewW + gap;
+ imageX = rightX + Math.max(0, (rightW - imageW) / 2);
+ imageY = TOP_HEIGHT + Math.max(0, (maxH - imageH) / 2);
+ }
+
+ private void drawTexture(GuiGraphicsExtractor context) {
+ context.blit(
+ ScreenRenderer.textureIdentifier(screen.displayTextureId()),
+ imageX,
+ imageY,
+ imageX + imageW,
+ imageY + imageH,
+ 0,
+ 1,
+ 0,
+ 1
+ );
+ }
+
+ private void drawChrome(GuiGraphicsExtractor context) {
+ int margin = 14;
+ VpUiRenderer.drawBox(context, margin, 12, Math.max(1, width - margin * 2), Math.max(1, height - 24),
+ THEME.panelBackgroundColor(), THEME.panelBorderColor());
+ }
+
+ private void drawFrame(GuiGraphicsExtractor context, int x, int y, int frameWidth, int frameHeight) {
+ VpUiRenderer.drawBox(context, x - 3, y - 3, frameWidth + 6, frameHeight + 6,
+ VpUiRenderer.darken(THEME.nodeBodyColor(), 0.08f), THEME.panelBorderColor());
+ }
+
+ private void drawLabel(GuiGraphicsExtractor context, Component text, int x, int y, int color) {
+ if (THEME.textShadow()) {
+ context.text(font, text, x, y, color);
+ return;
+ }
+ context.text(font, text, x, y, color, false);
+ }
+
+ private void drawCenteredLabel(GuiGraphicsExtractor context, Component text, int centerX, int y, int color) {
+ drawLabel(context, text, centerX - font.width(text) / 2, y, color);
+ }
+
+ private VpButtonWidget button(String label, int x, int y, int width, Runnable action) {
+ VpButtonWidget button = new VpButtonWidget(x, y, Math.max(34, width), CONTROL_HEIGHT, Component.literal(label), b -> action.run(), THEME);
+ addRenderableWidget(button);
+ return button;
+ }
+
+ private VpButtonWidget button(Component label, int x, int y, int width, Runnable action) {
+ VpButtonWidget button = new VpButtonWidget(x, y, Math.max(34, width), CONTROL_HEIGHT, label, b -> action.run(), THEME);
+ addRenderableWidget(button);
+ return button;
+ }
+
+ private VpButtonWidget button(String label, int x, int y, int width, Consumer action) {
+ VpButtonWidget button = new VpButtonWidget(x, y, Math.max(34, width), CONTROL_HEIGHT, Component.literal(label), action, THEME);
+ addRenderableWidget(button);
+ return button;
+ }
+
+ private VpButtonWidget button(Component label, int x, int y, int width, Consumer action) {
+ VpButtonWidget button = new VpButtonWidget(x, y, Math.max(34, width), CONTROL_HEIGHT, label, action, THEME);
+ addRenderableWidget(button);
+ return button;
+ }
+
+ private void drawPreview(GuiGraphicsExtractor context) {
+ if (uvs.size() < 3) return;
+ ScreenGeometry geometry;
+ try {
+ geometry = screen.geometry();
+ } catch (IllegalArgumentException ignored) {
+ return;
+ }
+ if (uvs.size() != geometry.vertices().size()) return;
+
+ context.fill(previewX, previewY, previewX + previewW, previewY + previewH, VpUiRenderer.darken(THEME.nodeBodyColor(), 0.36f));
+ ArrayList projected = projectPreviewVertices(geometry);
+ if (projected == null) return;
+ drawPreview3dTexture(context, geometry, projected);
+ drawPreview3dOutline(context, geometry, projected);
+ }
+
+ private ArrayList projectPreviewVertices(ScreenGeometry geometry) {
+ List vertices = geometry.localVertices();
+ if (vertices.isEmpty()) return null;
+
+ Vector3f center = previewCenter(vertices);
+ ArrayList rotated = new ArrayList<>(vertices.size());
+ float minX = Float.POSITIVE_INFINITY;
+ float minY = Float.POSITIVE_INFINITY;
+ float maxX = Float.NEGATIVE_INFINITY;
+ float maxY = Float.NEGATIVE_INFINITY;
+ for (Vector3f vertex : vertices) {
+ PreviewVertex3d point = rotatePreviewVertex(vertex, center);
+ rotated.add(point);
+ minX = Math.min(minX, point.x);
+ minY = Math.min(minY, point.y);
+ maxX = Math.max(maxX, point.x);
+ maxY = Math.max(maxY, point.y);
+ }
+
+ float shapeW = Math.max(maxX - minX, ScreenGeometry.EPSILON);
+ float shapeH = Math.max(maxY - minY, ScreenGeometry.EPSILON);
+ float scale = Math.min((previewW - 20) / shapeW, (previewH - 20) / shapeH);
+ if (!Float.isFinite(scale) || scale <= 0) return null;
+
+ float centerX = (minX + maxX) * 0.5f;
+ float centerY = (minY + maxY) * 0.5f;
+ ArrayList projected = new ArrayList<>(rotated.size());
+ for (PreviewVertex3d point : rotated) {
+ projected.add(new PreviewVertex3d(
+ previewX + previewW * 0.5f + (point.x - centerX) * scale,
+ previewY + previewH * 0.5f - (point.y - centerY) * scale,
+ point.z
+ ));
+ }
+ return projected;
+ }
+
+ private Vector3f previewCenter(List vertices) {
+ Vector3f min = new Vector3f(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY);
+ Vector3f max = new Vector3f(Float.NEGATIVE_INFINITY, Float.NEGATIVE_INFINITY, Float.NEGATIVE_INFINITY);
+ for (Vector3f vertex : vertices) {
+ min.min(vertex);
+ max.max(vertex);
+ }
+ return min.add(max).mul(0.5f);
+ }
+
+ private PreviewVertex3d rotatePreviewVertex(Vector3f vertex, Vector3f center) {
+ float x = vertex.x - center.x;
+ float y = vertex.y - center.y;
+ float z = vertex.z - center.z;
+
+ float cy = (float) Math.cos(previewYaw);
+ float sy = (float) Math.sin(previewYaw);
+ float yawX = x * cy + z * sy;
+ float yawZ = -x * sy + z * cy;
+
+ float cp = (float) Math.cos(previewPitch);
+ float sp = (float) Math.sin(previewPitch);
+ float pitchY = y * cp - yawZ * sp;
+ float pitchZ = y * sp + yawZ * cp;
+ return new PreviewVertex3d(yawX, pitchY, pitchZ);
+ }
+
+ private void drawPreview3dTexture(GuiGraphicsExtractor context, ScreenGeometry geometry, ArrayList projected) {
+ int[] triangles = geometry.triangles();
+ ArrayList vertices = new ArrayList<>(triangles.length);
+ for (int i = 0; i < triangles.length; i += 3) {
+ addPreview3dVertex(vertices, projected, triangles[i], uvs.get(triangles[i]));
+ addPreview3dVertex(vertices, projected, triangles[i + 1], uvs.get(triangles[i + 1]));
+ addPreview3dVertex(vertices, projected, triangles[i + 2], uvs.get(triangles[i + 2]));
+ }
+ ScreenRenderer.drawGuiTexturedTriangles(context, screen.displayTextureId(), vertices);
+ }
+
+ private void addPreview3dVertex(ArrayList vertices, ArrayList projected, int index, Vector2f uv) {
+ PreviewVertex3d point = projected.get(index);
+ vertices.add(new ScreenRenderer.GuiVertex(point.x, point.y, uv.x, uv.y, 0xFFFFFFFF));
+ }
+
+ private void drawPreview3dOutline(GuiGraphicsExtractor context, ScreenGeometry geometry, ArrayList projected) {
+ int count = geometry.vertices().size();
+ for (int i = 0; i < count; i++) {
+ PreviewVertex3d a = projected.get(i);
+ PreviewVertex3d b = projected.get((i + 1) % count);
+ drawLine(context, a.x, a.y, b.x, b.y, PREVIEW_OUTLINE_COLOR, GUIDE_LINE_WIDTH);
+ }
+ }
+
+ private void drawPolygon(GuiGraphicsExtractor context) {
+ if (uvs.size() < 2) return;
+ for (int i = 0; i < uvs.size(); i++) {
+ Vector2f a = uvs.get(i);
+ Vector2f b = uvs.get((i + 1) % uvs.size());
+ drawLine(context, toX(a), toY(a), toX(b), toY(b), OUTLINE_COLOR, GUIDE_LINE_WIDTH);
+ }
+ drawTriangleGuides(context);
+ }
+
+ private void drawTriangleGuides(GuiGraphicsExtractor context) {
+ if (uvs.size() < 4) return;
+ ScreenGeometry geometry;
+ try {
+ geometry = screen.geometry();
+ } catch (IllegalArgumentException ignored) {
+ return;
+ }
+ if (uvs.size() != geometry.vertices().size()) return;
+ int[] triangles = geometry.triangles();
+ for (int i = 0; i < triangles.length; i += 3) {
+ drawTriangleGuideEdge(context, triangles[i], triangles[i + 1]);
+ drawTriangleGuideEdge(context, triangles[i + 1], triangles[i + 2]);
+ drawTriangleGuideEdge(context, triangles[i + 2], triangles[i]);
+ }
+ }
+
+ private void drawTriangleGuideEdge(GuiGraphicsExtractor context, int from, int to) {
+ int size = uvs.size();
+ if (from == to) return;
+ int diff = Math.abs(from - to);
+ if (diff == 1 || diff == size - 1) return;
+ Vector2f a = uvs.get(from);
+ Vector2f b = uvs.get(to);
+ drawLine(context, toX(a), toY(a), toX(b), toY(b), TRIANGLE_GUIDE_COLOR, GUIDE_LINE_WIDTH);
+ }
+
+ private void drawHandles(GuiGraphicsExtractor context, int mouseX, int mouseY) {
+ pruneSelection();
+ for (int i = 0; i < uvs.size(); i++) {
+ Vector2f uv = uvs.get(i);
+ int x = Math.round(toX(uv));
+ int y = Math.round(toY(uv));
+ boolean selected = selectedVertices.contains(i);
+ boolean hot = i == dragging || dragMode == DragMode.SELECTION && selected
+ || Math.abs(mouseX - x) <= HANDLE_HIT_SIZE && Math.abs(mouseY - y) <= HANDLE_HIT_SIZE;
+ int color = hot ? THEME.errorColor() : selected ? THEME.executionColor() : THEME.accentColor();
+ context.fill(x - HANDLE_SIZE, y - HANDLE_SIZE, x + HANDLE_SIZE + 1, y + HANDLE_SIZE + 1, color);
+ String label = String.valueOf(i + 1);
+ int labelX = x + 7;
+ int labelY = y - 5;
+ context.fill(labelX - 2, labelY - 1, labelX + font.width(label) + 2, labelY + 10,
+ VpUiRenderer.withAlpha(THEME.panelBackgroundColor(), 0xCC));
+ drawLabel(context, Component.literal(label), labelX, labelY, THEME.primaryTextColor());
+ }
+ }
+
+ private void drawSelectionBox(GuiGraphicsExtractor context) {
+ if (dragMode != DragMode.BOX_SELECT) return;
+ int x1 = Math.round(Math.clamp((float) Math.min(selectionStartX, selectionEndX), imageX, imageX + imageW));
+ int y1 = Math.round(Math.clamp((float) Math.min(selectionStartY, selectionEndY), imageY, imageY + imageH));
+ int x2 = Math.round(Math.clamp((float) Math.max(selectionStartX, selectionEndX), imageX, imageX + imageW));
+ int y2 = Math.round(Math.clamp((float) Math.max(selectionStartY, selectionEndY), imageY, imageY + imageH));
+ if (x2 <= x1 || y2 <= y1) return;
+ context.fill(x1, y1, x2, y2, VpUiRenderer.withAlpha(THEME.executionColor(), 0x28));
+ context.outline(x1, y1, x2 - x1, y2 - y1, THEME.executionColor());
+ }
+
+ private void drawLine(GuiGraphicsExtractor context, float x1, float y1, float x2, float y2, int color, float width) {
+ float dx = x2 - x1;
+ float dy = y2 - y1;
+ float length = (float) Math.sqrt(dx * dx + dy * dy);
+ if (length < 0.001f) return;
+ int thickness = Math.max(1, Math.round(width));
+ int half = Math.max(1, thickness) / 2;
+ context.pose().pushMatrix();
+ context.pose().translate(x1, y1);
+ context.pose().rotate((float) Math.atan2(dy, dx));
+ context.fill(0, -half, Math.max(1, Math.round(length)), Math.max(1, thickness - half), color);
+ context.pose().popMatrix();
+ }
+
+ private int handleAt(double mouseX, double mouseY) {
+ for (int i = uvs.size() - 1; i >= 0; i--) {
+ Vector2f uv = uvs.get(i);
+ if (Math.abs(mouseX - toX(uv)) <= HANDLE_HIT_SIZE && Math.abs(mouseY - toY(uv)) <= HANDLE_HIT_SIZE) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ private int edgeAt(double mouseX, double mouseY) {
+ if (uvs.size() < 2) return -1;
+ float maxDistanceSq = EDGE_HIT_SIZE * EDGE_HIT_SIZE;
+ for (int i = uvs.size() - 1; i >= 0; i--) {
+ Vector2f a = uvs.get(i);
+ Vector2f b = uvs.get((i + 1) % uvs.size());
+ if (distanceToSegmentSq((float) mouseX, (float) mouseY, toX(a), toY(a), toX(b), toY(b)) <= maxDistanceSq) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ private void toggleSelectedVertex(int index) {
+ if (index < 0 || index >= uvs.size()) return;
+ if (!selectedVertices.remove(index)) {
+ selectedVertices.add(index);
+ }
+ }
+
+ private void pruneSelection() {
+ selectedVertices.removeIf(index -> index < 0 || index >= uvs.size());
+ }
+
+ private void selectVerticesInBox() {
+ selectedVertices.clear();
+ int x1 = Math.round(Math.clamp((float) Math.min(selectionStartX, selectionEndX), imageX, imageX + imageW));
+ int y1 = Math.round(Math.clamp((float) Math.min(selectionStartY, selectionEndY), imageY, imageY + imageH));
+ int x2 = Math.round(Math.clamp((float) Math.max(selectionStartX, selectionEndX), imageX, imageX + imageW));
+ int y2 = Math.round(Math.clamp((float) Math.max(selectionStartY, selectionEndY), imageY, imageY + imageH));
+ for (int i = 0; i < uvs.size(); i++) {
+ Vector2f uv = uvs.get(i);
+ float x = toX(uv);
+ float y = toY(uv);
+ if (x >= x1 && x <= x2 && y >= y1 && y <= y2) {
+ selectedVertices.add(i);
+ }
+ }
+ }
+
+ private boolean insideSelectionBounds(double mouseX, double mouseY) {
+ if (selectedVertices.isEmpty()) return false;
+ Bounds bounds = selectedBounds(uvs);
+ if (bounds == null) return false;
+ float padding = HANDLE_HIT_SIZE;
+ return mouseX >= toX(bounds.minX) - padding && mouseX <= toX(bounds.maxX) + padding
+ && mouseY >= toY(bounds.minY) - padding && mouseY <= toY(bounds.maxY) + padding;
+ }
+
+ private Bounds selectedBounds(List points) {
+ if (points == null || points.isEmpty()) return null;
+ float minX = Float.POSITIVE_INFINITY;
+ float minY = Float.POSITIVE_INFINITY;
+ float maxX = Float.NEGATIVE_INFINITY;
+ float maxY = Float.NEGATIVE_INFINITY;
+ boolean found = false;
+ for (int index : selectedVertices) {
+ if (index < 0 || index >= points.size()) continue;
+ Vector2f uv = points.get(index);
+ minX = Math.min(minX, uv.x);
+ minY = Math.min(minY, uv.y);
+ maxX = Math.max(maxX, uv.x);
+ maxY = Math.max(maxY, uv.y);
+ found = true;
+ }
+ return found ? new Bounds(minX, minY, maxX, maxY) : null;
+ }
+
+ private float distanceToSegmentSq(float px, float py, float x1, float y1, float x2, float y2) {
+ float dx = x2 - x1;
+ float dy = y2 - y1;
+ float lengthSq = dx * dx + dy * dy;
+ if (lengthSq < 0.001f) {
+ float x = px - x1;
+ float y = py - y1;
+ return x * x + y * y;
+ }
+ float t = ((px - x1) * dx + (py - y1) * dy) / lengthSq;
+ t = Math.clamp(t, 0, 1);
+ float x = x1 + dx * t;
+ float y = y1 + dy * t;
+ float ox = px - x;
+ float oy = py - y;
+ return ox * ox + oy * oy;
+ }
+
+ private void markDirty() {
+ dirty = true;
+ editRevision++;
+ }
+
+ private void saveIfDirty() {
+ if (dirty) save();
+ }
+
+ private void save() {
+ save(null);
+ }
+
+ private void save(VpButtonWidget button) {
+ if (!canEditMapping() || savePending) return;
+ float[] submitted = toFloatArray();
+ long submittedRevision = editRevision;
+ savePending = true;
+ dirty = false;
+ refreshSaveControls();
+ ClientPacketHandler.setMetadata(screen, ScreenMetadata.KEY_MAPPING_UVS, MetaValue.ofFloatArray(submitted), result -> {
+ savePending = false;
+ if (ClientPacketHandler.denied(result) && button != null) button.showPermissionDenied();
+ boolean success = result != null && result.status() == com.github.squi2rel.vp.network.RequestResultStatus.OK;
+ boolean newerEdit = editRevision != submittedRevision;
+ if (newerEdit) {
+ dirty = true;
+ if (success && currentScreen() && canEditMapping()) {
+ save();
+ }
+ } else {
+ dirty = !success;
+ }
+ refreshSaveControls();
+ });
+ }
+
+ private void refreshSaveControls() {
+ boolean active = currentScreen() && canEditMapping() && !savePending;
+ if (saveButton != null) saveButton.active = active;
+ if (resetButton != null) resetButton.active = active;
+ }
+
+ private boolean currentScreen() {
+ return screen.area != null
+ && VideoPlayerClient.screens.contains(screen)
+ && screen.area.getScreen(screen.name) == screen;
+ }
+
+ private boolean canEditMapping() {
+ return ClientPermissionCache.allowedOrUnknown(VideoPermissionAction.SET_METADATA, screen);
+ }
+
+ private float[] toFloatArray() {
+ float[] result = new float[uvs.size() * 2];
+ for (int i = 0; i < uvs.size(); i++) {
+ result[i * 2] = uvs.get(i).x;
+ result[i * 2 + 1] = uvs.get(i).y;
+ }
+ return result;
+ }
+
+ private boolean resetFromGeometry() {
+ ScreenGeometry geometry;
+ try {
+ geometry = screen.geometry();
+ } catch (IllegalArgumentException ignored) {
+ return false;
+ }
+
+ ArrayList points = new ArrayList<>();
+ float minU = Float.POSITIVE_INFINITY;
+ float minV = Float.POSITIVE_INFINITY;
+ float maxU = Float.NEGATIVE_INFINITY;
+ float maxV = Float.NEGATIVE_INFINITY;
+ for (int i = 0; i < geometry.vertices().size(); i++) {
+ Vector2f point = geometry.editPoint(i);
+ points.add(point);
+ minU = Math.min(minU, point.x);
+ minV = Math.min(minV, point.y);
+ maxU = Math.max(maxU, point.x);
+ maxV = Math.max(maxV, point.y);
+ }
+
+ float width = maxU - minU;
+ float height = maxV - minV;
+ if (width <= ScreenGeometry.EPSILON || height <= ScreenGeometry.EPSILON) return false;
+ float screenAspect = width / height;
+ float textureAspect = screen.displayTextureWidth() / (float) Math.max(1, screen.displayTextureHeight());
+ float usedW = 1;
+ float usedH = 1;
+ if (screenAspect > textureAspect) {
+ usedH = textureAspect / screenAspect;
+ } else {
+ usedW = screenAspect / textureAspect;
+ }
+ float left = (1 - usedW) * 0.5f;
+ float top = (1 - usedH) * 0.5f;
+
+ for (Vector2f point : points) {
+ uvs.add(new Vector2f(
+ clamp01(left + ((point.x - minU) / width) * usedW),
+ clamp01(top + ((point.y - minV) / height) * usedH)
+ ));
+ }
+ return !uvs.isEmpty();
+ }
+
+ private void translate(float dx, float dy) {
+ if (uvs.isEmpty()) return;
+ for (Vector2f uv : uvs) {
+ uv.x = clamp01(uv.x + dx);
+ uv.y = clamp01(uv.y + dy);
+ }
+ markDirty();
+ }
+
+ private void moveSelected(double mouseX, double mouseY) {
+ pruneSelection();
+ if (selectedVertices.isEmpty() || dragStartUvs.size() != uvs.size() || imageW <= 0 || imageH <= 0) return;
+ float dx = (float) (mouseX - dragStartMouseX) / imageW;
+ float dy = (float) (mouseY - dragStartMouseY) / imageH;
+ Bounds bounds = selectedBounds(dragStartUvs);
+ if (bounds == null) return;
+ dx = clampMovement(dx, -bounds.minX, 1 - bounds.maxX);
+ dy = clampMovement(dy, -bounds.minY, 1 - bounds.maxY);
+
+ boolean changed = false;
+ for (int index : selectedVertices) {
+ if (index < 0 || index >= uvs.size()) continue;
+ Vector2f start = dragStartUvs.get(index);
+ Vector2f uv = uvs.get(index);
+ float x = start.x + dx;
+ float y = start.y + dy;
+ if (Math.abs(uv.x - x) > 0.000001f || Math.abs(uv.y - y) > 0.000001f) {
+ changed = true;
+ }
+ uv.set(x, y);
+ }
+ if (changed) markDirty();
+ }
+
+ private float clampMovement(float value, float min, float max) {
+ if (min <= max) return Math.clamp(value, min, max);
+ float middle = (min + max) * 0.5f;
+ return value < middle ? max : min;
+ }
+
+ private void moveEdge(double mouseX, double mouseY, boolean snapAxis) {
+ if (draggingEdge < 0 || draggingEdge >= uvs.size() || imageW <= 0 || imageH <= 0) return;
+ Vector2f a = uvs.get(draggingEdge);
+ Vector2f b = uvs.get((draggingEdge + 1) % uvs.size());
+ float mouseDx = (float) (mouseX - dragStartMouseX);
+ float mouseDy = (float) (mouseY - dragStartMouseY);
+ float moveX = mouseDx / imageW;
+ float moveY = mouseDy / imageH;
+ if (snapAxis) {
+ float edgeX = (edgeStartBX - edgeStartAX) * imageW;
+ float edgeY = (edgeStartBY - edgeStartAY) * imageH;
+ float edgeLength = (float) Math.sqrt(edgeX * edgeX + edgeY * edgeY);
+ if (edgeLength > 0.001f) {
+ float dirX = edgeX / edgeLength;
+ float dirY = edgeY / edgeLength;
+ float perpendicularX = -dirY;
+ float perpendicularY = dirX;
+ float along = mouseDx * dirX + mouseDy * dirY;
+ float perpendicular = mouseDx * perpendicularX + mouseDy * perpendicularY;
+ if (Math.abs(along) >= Math.abs(perpendicular)) {
+ moveX = dirX * along / imageW;
+ moveY = dirY * along / imageH;
+ } else {
+ moveX = perpendicularX * perpendicular / imageW;
+ moveY = perpendicularY * perpendicular / imageH;
+ }
+ }
+ }
+
+ float minX = Math.min(edgeStartAX, edgeStartBX);
+ float maxX = Math.max(edgeStartAX, edgeStartBX);
+ float minY = Math.min(edgeStartAY, edgeStartBY);
+ float maxY = Math.max(edgeStartAY, edgeStartBY);
+ moveX = clampMovement(moveX, -minX, 1 - maxX);
+ moveY = clampMovement(moveY, -minY, 1 - maxY);
+ if (Math.abs(moveX) < 0.000001f && Math.abs(moveY) < 0.000001f) return;
+
+ a.x = edgeStartAX + moveX;
+ a.y = edgeStartAY + moveY;
+ b.x = edgeStartBX + moveX;
+ b.y = edgeStartBY + moveY;
+ markDirty();
+ }
+
+ private void scale(float factor) {
+ if (uvs.isEmpty()) return;
+ Vector2f center = center();
+ factor = constrainedScaleFactor(center, factor);
+ if (Math.abs(factor - 1.0f) < 0.000001f) return;
+ boolean changed = false;
+ for (Vector2f uv : uvs) {
+ float x = snapUnit(center.x + (uv.x - center.x) * factor);
+ float y = snapUnit(center.y + (uv.y - center.y) * factor);
+ if (Math.abs(uv.x - x) > 0.000001f || Math.abs(uv.y - y) > 0.000001f) {
+ changed = true;
+ }
+ uv.set(x, y);
+ }
+ if (changed) markDirty();
+ }
+
+ private float constrainedScaleFactor(Vector2f center, float requested) {
+ if (requested <= 1.0f) return requested;
+ float maxFactor = Float.POSITIVE_INFINITY;
+ for (Vector2f uv : uvs) {
+ maxFactor = Math.min(maxFactor, maxScaleForAxis(center.x, uv.x));
+ maxFactor = Math.min(maxFactor, maxScaleForAxis(center.y, uv.y));
+ }
+ if (!Float.isFinite(maxFactor)) return requested;
+ if (maxFactor < 1.0f && maxFactor > 0.999999f) {
+ maxFactor = 1.0f;
+ }
+ return Math.min(requested, Math.max(0.0f, maxFactor));
+ }
+
+ private float maxScaleForAxis(float center, float value) {
+ float delta = value - center;
+ if (Math.abs(delta) < 0.000001f) return Float.POSITIVE_INFINITY;
+ if (delta > 0) return (1.0f - center) / delta;
+ return center / -delta;
+ }
+
+ private float snapUnit(float value) {
+ if (value < 0.0f && value > -0.000001f) return 0.0f;
+ if (value > 1.0f && value < 1.000001f) return 1.0f;
+ return value;
+ }
+
+ private void rotateFromStart(float radians) {
+ if (uvs.isEmpty() || dragStartUvs.size() != uvs.size()) return;
+ Vector2f center = dragStartCenter;
+ float cos = (float) Math.cos(radians);
+ float sin = (float) Math.sin(radians);
+ boolean changed = false;
+ for (int i = 0; i < uvs.size(); i++) {
+ Vector2f start = dragStartUvs.get(i);
+ Vector2f uv = uvs.get(i);
+ float nextX;
+ float nextY;
+ if (keepAspect) {
+ float sx = Math.max(1, imageW);
+ float sy = Math.max(1, imageH);
+ float x = (start.x - center.x) * sx;
+ float y = (start.y - center.y) * sy;
+ nextX = center.x + (x * cos - y * sin) / sx;
+ nextY = center.y + (x * sin + y * cos) / sy;
+ } else {
+ float x = start.x - center.x;
+ float y = start.y - center.y;
+ nextX = center.x + x * cos - y * sin;
+ nextY = center.y + x * sin + y * cos;
+ }
+ if (Math.abs(uv.x - nextX) > 0.000001f || Math.abs(uv.y - nextY) > 0.000001f) {
+ changed = true;
+ }
+ uv.set(nextX, nextY);
+ }
+ if (changed) markDirty();
+ }
+
+ private void fitUvsInsideUnitSquare() {
+ if (uvs.isEmpty()) return;
+ Bounds bounds = bounds();
+ if (bounds == null) return;
+
+ boolean changed = false;
+ float boundsW = bounds.maxX - bounds.minX;
+ float boundsH = bounds.maxY - bounds.minY;
+ if (boundsW > 1 || boundsH > 1) {
+ Vector2f center = center();
+ float scale = Math.min(1 / boundsW, 1 / boundsH);
+ for (Vector2f uv : uvs) {
+ uv.x = center.x + (uv.x - center.x) * scale;
+ uv.y = center.y + (uv.y - center.y) * scale;
+ }
+ changed = true;
+ bounds = bounds();
+ if (bounds == null) return;
+ }
+
+ float dx = 0;
+ float dy = 0;
+ if (bounds.minX < 0) dx = -bounds.minX;
+ else if (bounds.maxX > 1) dx = 1 - bounds.maxX;
+ if (bounds.minY < 0) dy = -bounds.minY;
+ else if (bounds.maxY > 1) dy = 1 - bounds.maxY;
+ if (Math.abs(dx) > 0.000001f || Math.abs(dy) > 0.000001f) {
+ for (Vector2f uv : uvs) {
+ uv.x += dx;
+ uv.y += dy;
+ }
+ changed = true;
+ }
+
+ if (changed) markDirty();
+ }
+
+ private Bounds bounds() {
+ if (uvs.isEmpty()) return null;
+ float minX = Float.POSITIVE_INFINITY;
+ float minY = Float.POSITIVE_INFINITY;
+ float maxX = Float.NEGATIVE_INFINITY;
+ float maxY = Float.NEGATIVE_INFINITY;
+ for (Vector2f uv : uvs) {
+ minX = Math.min(minX, uv.x);
+ minY = Math.min(minY, uv.y);
+ maxX = Math.max(maxX, uv.x);
+ maxY = Math.max(maxY, uv.y);
+ }
+ return new Bounds(minX, minY, maxX, maxY);
+ }
+
+ private Vector2f center() {
+ return center(uvs);
+ }
+
+ private Vector2f center(List points) {
+ float x = 0;
+ float y = 0;
+ for (Vector2f uv : points) {
+ x += uv.x;
+ y += uv.y;
+ }
+ float count = Math.max(1, points.size());
+ return new Vector2f(x / count, y / count);
+ }
+
+ private void captureDragStartUvs() {
+ dragStartUvs.clear();
+ for (Vector2f uv : uvs) {
+ dragStartUvs.add(new Vector2f(uv));
+ }
+ }
+
+ private float angleAt(double mouseX, double mouseY, Vector2f center) {
+ return (float) Math.atan2((mouseY - imageY) / imageH - center.y, (mouseX - imageX) / imageW - center.x);
+ }
+
+ private float normalizedAngle(float radians) {
+ return (float) Math.atan2(Math.sin(radians), Math.cos(radians));
+ }
+
+ private boolean insideImage(double mouseX, double mouseY) {
+ return mouseX >= imageX && mouseX <= imageX + imageW && mouseY >= imageY && mouseY <= imageY + imageH;
+ }
+
+ private boolean insidePreview(double mouseX, double mouseY) {
+ return mouseX >= previewX && mouseX <= previewX + previewW && mouseY >= previewY && mouseY <= previewY + previewH;
+ }
+
+ private boolean insideMappedPolygon(double mouseX, double mouseY) {
+ if (uvs.size() < 3 || !insideImage(mouseX, mouseY)) return false;
+ Vector2f point = new Vector2f(
+ (float) ((mouseX - imageX) / imageW),
+ (float) ((mouseY - imageY) / imageH)
+ );
+ return ScreenGeometry.contains2d(point, uvs);
+ }
+
+ private float toX(Vector2f uv) {
+ return toX(uv.x);
+ }
+
+ private float toY(Vector2f uv) {
+ return toY(uv.y);
+ }
+
+ private float toX(float u) {
+ return imageX + u * imageW;
+ }
+
+ private float toY(float v) {
+ return imageY + v * imageH;
+ }
+
+ private float clamp01(float value) {
+ return Math.clamp(value, 0, 1);
+ }
+
+ private enum DragMode {
+ NONE,
+ HANDLE,
+ SELECTION,
+ BOX_SELECT,
+ EDGE,
+ PAN,
+ ROTATE,
+ PREVIEW_ROTATE
+ }
+
+ private record Bounds(float minX, float minY, float maxX, float maxY) {
+ }
+
+ private record PreviewVertex3d(float x, float y, float z) {
+ }
+}
diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/VpButtonWidget.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/VpButtonWidget.java
new file mode 100644
index 0000000..9cf6db6
--- /dev/null
+++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/VpButtonWidget.java
@@ -0,0 +1,161 @@
+package com.github.squi2rel.vp.creation;
+
+import com.github.squi2rel.vp.i18n.VpTexts;
+import java.util.function.Consumer;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.Font;
+import net.minecraft.client.gui.GuiGraphicsExtractor;
+import net.minecraft.client.gui.components.AbstractWidget;
+import net.minecraft.client.gui.narration.NarrationElementOutput;
+import net.minecraft.client.input.KeyEvent;
+import net.minecraft.client.input.MouseButtonEvent;
+import net.minecraft.network.chat.Component;
+
+class VpButtonWidget extends AbstractWidget {
+ private final VpUiTheme theme;
+ private final Consumer onPress;
+ private boolean selected;
+ private boolean danger;
+ private boolean clipped;
+ private int clipLeft;
+ private int clipTop;
+ private int clipRight;
+ private int clipBottom;
+ private Component temporaryMessage;
+ private long temporaryMessageUntil;
+
+ VpButtonWidget(int x, int y, int width, int height, Component message, Consumer onPress, VpUiTheme theme) {
+ super(x, y, width, height, message);
+ this.theme = theme;
+ this.onPress = onPress;
+ }
+
+ VpButtonWidget selected(boolean selected) {
+ this.selected = selected;
+ return this;
+ }
+
+ VpButtonWidget danger(boolean danger) {
+ this.danger = danger;
+ return this;
+ }
+
+ VpButtonWidget clip(int left, int top, int right, int bottom) {
+ this.clipped = true;
+ this.clipLeft = left;
+ this.clipTop = top;
+ this.clipRight = right;
+ this.clipBottom = bottom;
+ return this;
+ }
+
+ void showTemporaryLabel(String label, long millis) {
+ temporaryMessage = Component.literal(label == null ? "" : label);
+ temporaryMessageUntil = System.currentTimeMillis() + Math.max(0, millis);
+ }
+
+ void showTemporaryLabel(Component label, long millis) {
+ temporaryMessage = label == null ? Component.empty() : label;
+ temporaryMessageUntil = System.currentTimeMillis() + Math.max(0, millis);
+ }
+
+ void showPermissionDenied() {
+ showTemporaryLabel(VpTexts.tr("error.videoplayer.permission_denied", "Permission denied"), 1500L);
+ }
+
+ @Override
+ public boolean isMouseOver(double mouseX, double mouseY) {
+ return super.isMouseOver(mouseX, mouseY) && (!clipped || insideClip(mouseX, mouseY));
+ }
+
+ @Override
+ protected void extractWidgetRenderState(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
+ int fill = fillColor();
+ int border = borderColor();
+ int textColor = textColor();
+ VpUiRenderer.drawBox(context, getX(), getY(), getWidth(), getHeight(), fill, border);
+ Font textRenderer = Minecraft.getInstance().font;
+ drawButtonText(context, textRenderer, textColor);
+ }
+
+ @Override
+ public void onClick(MouseButtonEvent click, boolean doubleClick) {
+ onPress.accept(this);
+ }
+
+ @Override
+ public boolean keyPressed(KeyEvent input) {
+ if (!active || !visible || !input.isSelection()) return false;
+ onPress.accept(this);
+ return true;
+ }
+
+ @Override
+ protected void updateWidgetNarration(NarrationElementOutput builder) {
+ defaultButtonNarrationText(builder);
+ }
+
+ private void drawButtonText(GuiGraphicsExtractor context, Font textRenderer, int color) {
+ int left = getX() + 4;
+ int right = getRight() - 4;
+ int innerWidth = Math.max(1, right - left);
+ String label = displayMessage().getString();
+ String visibleLabel = textRenderer.width(label) > innerWidth ? textRenderer.plainSubstrByWidth(label, innerWidth) : label;
+ Component visibleText = Component.literal(visibleLabel);
+ int textWidth = textRenderer.width(visibleLabel);
+ int textX = left + Math.max(0, (innerWidth - textWidth) / 2);
+ int textY = getY() + Math.max(1, (getHeight() - textRenderer.lineHeight) / 2);
+ if (theme.textShadow()) {
+ context.text(textRenderer, visibleText, textX, textY, color);
+ return;
+ }
+ context.text(textRenderer, visibleText, textX, textY, color, false);
+ }
+
+ private int fillColor() {
+ int base = VpUiRenderer.darken(theme.nodeBodyColor(), 0.04f);
+ if (danger && (selected || isHovered())) {
+ return VpUiRenderer.blend(base, theme.errorColor(), selected ? 0.22f : 0.12f);
+ }
+ if (selected) {
+ return VpUiRenderer.blend(base, theme.accentColor(), 0.20f);
+ }
+ if (!active) {
+ return VpUiRenderer.blend(base, theme.canvasBackgroundColor(), 0.36f);
+ }
+ if (isHovered()) {
+ return VpUiRenderer.blend(base, theme.accentColor(), 0.11f);
+ }
+ return base;
+ }
+
+ private Component displayMessage() {
+ if (temporaryMessage != null && System.currentTimeMillis() < temporaryMessageUntil) {
+ return temporaryMessage;
+ }
+ temporaryMessage = null;
+ return getMessage();
+ }
+
+ private int borderColor() {
+ if (danger && (selected || isHovered())) return theme.errorColor();
+ if (selected) return theme.accentColor();
+ if (!active) return VpUiRenderer.blend(theme.panelBorderColor(), theme.canvasBackgroundColor(), 0.45f);
+ if (isHovered()) return VpUiRenderer.blend(theme.panelBorderColor(), theme.accentColor(), 0.48f);
+ return theme.panelBorderColor();
+ }
+
+ private int textColor() {
+ if (danger && selected) return theme.errorColor();
+ if (selected) return theme.primaryTextColor();
+ if (!active) return VpUiRenderer.blend(theme.secondaryTextColor(), theme.canvasBackgroundColor(), 0.45f);
+ return isHovered() ? theme.primaryTextColor() : theme.secondaryTextColor();
+ }
+
+ private boolean insideClip(double mouseX, double mouseY) {
+ return mouseX >= clipLeft
+ && mouseY >= clipTop
+ && mouseX < clipRight
+ && mouseY < clipBottom;
+ }
+}
diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/VpProgressSliderWidget.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/VpProgressSliderWidget.java
new file mode 100644
index 0000000..5d4f90a
--- /dev/null
+++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/VpProgressSliderWidget.java
@@ -0,0 +1,224 @@
+package com.github.squi2rel.vp.creation;
+
+import com.github.squi2rel.vp.i18n.VpTexts;
+import java.util.function.LongConsumer;
+import java.util.function.Supplier;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.Font;
+import net.minecraft.client.gui.GuiGraphicsExtractor;
+import net.minecraft.client.gui.components.AbstractSliderButton;
+import net.minecraft.client.input.KeyEvent;
+import net.minecraft.client.input.MouseButtonEvent;
+import net.minecraft.network.chat.Component;
+
+class VpProgressSliderWidget extends AbstractSliderButton {
+ private final Supplier source;
+ private final LongConsumer onPreview;
+ private final LongConsumer onCommit;
+ private final Runnable onDragStart;
+ private final Runnable onDragEnd;
+ private final VpUiTheme theme;
+ private ProgressState state = ProgressState.disabled();
+ private boolean dragging;
+ private long dragProgress;
+
+ VpProgressSliderWidget(int x, int y, int width, int height, Supplier source,
+ LongConsumer onPreview, LongConsumer onCommit,
+ Runnable onDragStart, Runnable onDragEnd, VpUiTheme theme) {
+ super(x, y, Math.max(80, width), height, Component.empty(), 0.0);
+ this.source = source;
+ this.onPreview = onPreview;
+ this.onCommit = onCommit;
+ this.onDragStart = onDragStart;
+ this.onDragEnd = onDragEnd;
+ this.theme = theme;
+ updateState();
+ updateMessage();
+ }
+
+ boolean dragging() {
+ return dragging;
+ }
+
+ long dragProgress() {
+ return dragProgress;
+ }
+
+ boolean containsPoint(double mouseX, double mouseY) {
+ return visible
+ && mouseX >= getX()
+ && mouseY >= getY()
+ && mouseX < getX() + getWidth()
+ && mouseY < getY() + getHeight();
+ }
+
+ @Override
+ public boolean mouseClicked(MouseButtonEvent click, boolean doubleClick) {
+ updateState();
+ if (!active || click.button() != 0 || !isMouseOver(click.x(), click.y())) {
+ return false;
+ }
+ dragging = true;
+ onDragStart.run();
+ boolean handled = super.mouseClicked(click, doubleClick);
+ if (!handled) {
+ dragging = false;
+ onDragEnd.run();
+ return false;
+ }
+ previewCurrentValue();
+ return true;
+ }
+
+ @Override
+ public boolean mouseDragged(MouseButtonEvent click, double deltaX, double deltaY) {
+ if (!dragging || !active || click.button() != 0) {
+ return false;
+ }
+ return super.mouseDragged(click, deltaX, deltaY);
+ }
+
+ @Override
+ public void onRelease(MouseButtonEvent click) {
+ boolean wasDragging = dragging;
+ super.onRelease(click);
+ if (!wasDragging) return;
+ dragging = false;
+ onCommit.accept(dragProgress);
+ onDragEnd.run();
+ }
+
+ @Override
+ public boolean keyPressed(KeyEvent input) {
+ updateState();
+ if (!active) return false;
+ boolean handled = super.keyPressed(input);
+ if (handled) {
+ previewCurrentValue();
+ onCommit.accept(dragProgress);
+ }
+ return handled;
+ }
+
+ @Override
+ protected void applyValue() {
+ previewCurrentValue();
+ }
+
+ @Override
+ protected void updateMessage() {
+ if (!state.available) {
+ setMessage(VpTexts.tr("label.videoplayer.not_adjustable", "Not adjustable"));
+ return;
+ }
+ if (!state.seekable) {
+ setMessage(Component.literal(formatDuration(state.total, state.total)));
+ return;
+ }
+ long progress = dragging ? dragProgress : state.progress;
+ setMessage(Component.literal(formatDuration(progress, state.total) + "/" + formatDuration(state.total, state.total)));
+ }
+
+ @Override
+ public void extractWidgetRenderState(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
+ updateState();
+ int fill = VpUiRenderer.darken(theme.nodeBodyColor(), active ? 0.04f : 0.12f);
+ int border = isHovered() || isFocused() ? VpUiRenderer.blend(theme.panelBorderColor(), theme.accentColor(), 0.48f) : theme.panelBorderColor();
+ if (!active) border = VpUiRenderer.blend(border, theme.canvasBackgroundColor(), 0.45f);
+ VpUiRenderer.drawBox(context, getX(), getY(), getWidth(), getHeight(), fill, border);
+
+ int trackX = getX() + 6;
+ int trackY = getY() + getHeight() - 6;
+ int trackW = Math.max(1, getWidth() - 12);
+ int trackColor = VpUiRenderer.blend(theme.panelBorderColor(), theme.canvasBackgroundColor(), 0.20f);
+ int fillW = Math.round(trackW * (float) value);
+ context.fill(trackX, trackY, trackX + trackW, trackY + 2, trackColor);
+ context.fill(trackX, trackY, trackX + fillW, trackY + 2, active ? theme.accentColor() : VpUiRenderer.blend(theme.accentColor(), theme.canvasBackgroundColor(), 0.55f));
+
+ int knobX = trackX + Math.clamp(fillW, 0, trackW) - 2;
+ context.fill(knobX, trackY - 2, knobX + 4, trackY + 4, active ? theme.primaryTextColor() : VpUiRenderer.blend(theme.primaryTextColor(), theme.canvasBackgroundColor(), 0.55f));
+
+ Font textRenderer = Minecraft.getInstance().font;
+ int textColor = state.available ? theme.secondaryTextColor() : VpUiRenderer.blend(theme.secondaryTextColor(), theme.canvasBackgroundColor(), 0.45f);
+ drawProgressText(context, textRenderer, textColor);
+ }
+
+ private void updateState() {
+ state = source.get();
+ active = state.seekable;
+ if (!dragging) {
+ value = !state.available ? 0 : !state.seekable ? 1 : Math.clamp((double) state.progress / (double) state.total, 0.0, 1.0);
+ dragProgress = state.progress;
+ }
+ updateMessage();
+ }
+
+ private void previewCurrentValue() {
+ if (!state.seekable || state.total <= 0) return;
+ dragProgress = Math.clamp(Math.round(value * state.total), 0, state.total);
+ updateMessage();
+ onPreview.accept(dragProgress);
+ }
+
+ private void drawProgressText(GuiGraphicsExtractor context, Font textRenderer, int textColor) {
+ if (!state.available) {
+ drawText(context, textRenderer, getMessage().getString(), getX() + 4, textColor);
+ return;
+ }
+
+ String totalText = trimText(textRenderer, formatDuration(state.total, state.total), getWidth() - 8);
+ int totalX = getX() + getWidth() - 4 - textRenderer.width(totalText);
+ drawText(context, textRenderer, totalText, totalX, textColor);
+ if (!state.seekable) return;
+
+ String progressText = formatDuration(dragging ? dragProgress : state.progress, state.total);
+ int maxProgressWidth = Math.max(0, totalX - getX() - 8);
+ String visibleProgressText = trimText(textRenderer, progressText, maxProgressWidth);
+ drawText(context, textRenderer, visibleProgressText, getX() + 4, textColor);
+ }
+
+ private void drawText(GuiGraphicsExtractor context, Font textRenderer, String text, int x, int color) {
+ if (text == null || text.isEmpty()) return;
+ if (theme.textShadow()) {
+ context.text(textRenderer, text, x, getY() + 2, color);
+ return;
+ }
+ context.text(textRenderer, text, x, getY() + 2, color, false);
+ }
+
+ private static String trimText(Font textRenderer, String text, int maxWidth) {
+ if (maxWidth <= 0) return "";
+ return textRenderer.width(text) > maxWidth ? textRenderer.plainSubstrByWidth(text, maxWidth) : text;
+ }
+
+ private static String formatDuration(long millis, long totalMillis) {
+ long safeMillis = Math.max(0, millis);
+ long totalSeconds = safeMillis / 1000;
+ long seconds = totalSeconds % 60;
+ long minutes = (totalSeconds / 60) % 60;
+ long hours = totalSeconds / 3600;
+ boolean showHours = hours > 0 || totalMillis >= 3_600_000L;
+ if (showHours) {
+ return "%d:%02d:%02d".formatted(hours, minutes, seconds);
+ }
+ return "%d:%02d".formatted(minutes, seconds);
+ }
+
+ record ProgressState(boolean available, boolean seekable, long progress, long total) {
+ static ProgressState disabled() {
+ return new ProgressState(false, false, 0, 0);
+ }
+
+ static ProgressState of(long progress, long total) {
+ long safeTotal = Math.max(0, total);
+ if (safeTotal <= 0) return disabled();
+ return new ProgressState(true, true, Math.clamp(progress, 0, safeTotal), safeTotal);
+ }
+
+ static ProgressState readonly(long total) {
+ long safeTotal = Math.max(0, total);
+ if (safeTotal <= 0) return disabled();
+ return new ProgressState(true, false, safeTotal, safeTotal);
+ }
+ }
+}
diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/VpSliderWidget.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/VpSliderWidget.java
new file mode 100644
index 0000000..6345e86
--- /dev/null
+++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/VpSliderWidget.java
@@ -0,0 +1,128 @@
+package com.github.squi2rel.vp.creation;
+
+import java.util.function.IntConsumer;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.Font;
+import net.minecraft.client.gui.GuiGraphicsExtractor;
+import net.minecraft.client.gui.components.AbstractSliderButton;
+import net.minecraft.client.input.KeyEvent;
+import net.minecraft.client.input.MouseButtonEvent;
+import net.minecraft.network.chat.Component;
+
+class VpSliderWidget extends AbstractSliderButton {
+ private final VpUiTheme theme;
+ private final IntConsumer onPreview;
+ private final IntConsumer onCommit;
+ private final TextFormatter messageFormatter;
+ private int intValue;
+ private boolean clipped;
+ private int clipLeft;
+ private int clipTop;
+ private int clipRight;
+ private int clipBottom;
+
+ VpSliderWidget(int x, int y, int width, int height, String label, int value,
+ IntConsumer onPreview, IntConsumer onCommit, VpUiTheme theme) {
+ this(x, y, width, height, label, value, onPreview, onCommit, value1 -> Component.literal(label + ": " + value1 + "%"), theme);
+ }
+
+ VpSliderWidget(int x, int y, int width, int height, Component label, int value,
+ IntConsumer onPreview, IntConsumer onCommit, VpUiTheme theme) {
+ this(x, y, width, height, "", value, onPreview, onCommit, value1 -> label.copy().append(": " + value1 + "%"), theme);
+ }
+
+ VpSliderWidget(int x, int y, int width, int height, String label, int value,
+ IntConsumer onPreview, IntConsumer onCommit, TextFormatter messageFormatter, VpUiTheme theme) {
+ super(x, y, Math.max(60, width), height, Component.empty(), Math.clamp(value, 0, 100) / 100.0);
+ this.theme = theme;
+ this.onPreview = onPreview;
+ this.onCommit = onCommit;
+ this.messageFormatter = messageFormatter;
+ this.intValue = Math.clamp(value, 0, 100);
+ updateMessage();
+ }
+
+ VpSliderWidget clip(int left, int top, int right, int bottom) {
+ this.clipped = true;
+ this.clipLeft = left;
+ this.clipTop = top;
+ this.clipRight = right;
+ this.clipBottom = bottom;
+ return this;
+ }
+
+ @Override
+ public boolean isMouseOver(double mouseX, double mouseY) {
+ return super.isMouseOver(mouseX, mouseY) && (!clipped || insideClip(mouseX, mouseY));
+ }
+
+ @Override
+ public void extractWidgetRenderState(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
+ int fill = VpUiRenderer.darken(theme.nodeBodyColor(), active ? 0.04f : 0.12f);
+ int border = isHovered() || isFocused() ? VpUiRenderer.blend(theme.panelBorderColor(), theme.accentColor(), 0.48f) : theme.panelBorderColor();
+ if (!active) border = VpUiRenderer.blend(border, theme.canvasBackgroundColor(), 0.45f);
+ VpUiRenderer.drawBox(context, getX(), getY(), getWidth(), getHeight(), fill, border);
+
+ int trackX = getX() + 6;
+ int trackY = getY() + getHeight() - 6;
+ int trackW = Math.max(1, getWidth() - 12);
+ int trackColor = VpUiRenderer.blend(theme.panelBorderColor(), theme.canvasBackgroundColor(), 0.20f);
+ int fillW = Math.round(trackW * (float) value);
+ context.fill(trackX, trackY, trackX + trackW, trackY + 2, trackColor);
+ context.fill(trackX, trackY, trackX + fillW, trackY + 2, theme.accentColor());
+
+ int knobX = trackX + Math.clamp(fillW, 0, trackW) - 2;
+ context.fill(knobX, trackY - 2, knobX + 4, trackY + 4, theme.primaryTextColor());
+
+ Font textRenderer = Minecraft.getInstance().font;
+ String text = getMessage().getString();
+ String visibleText = textRenderer.width(text) > getWidth() - 8 ? textRenderer.plainSubstrByWidth(text, getWidth() - 8) : text;
+ int textX = getX() + 4;
+ int textY = getY() + 2;
+ int textColor = active ? theme.secondaryTextColor() : VpUiRenderer.blend(theme.secondaryTextColor(), theme.canvasBackgroundColor(), 0.45f);
+ if (theme.textShadow()) {
+ context.text(textRenderer, visibleText, textX, textY, textColor);
+ return;
+ }
+ context.text(textRenderer, visibleText, textX, textY, textColor, false);
+ }
+
+ @Override
+ protected void updateMessage() {
+ setMessage(messageFormatter.apply(intValue));
+ }
+
+ @Override
+ protected void applyValue() {
+ int next = Math.clamp((int) Math.round(value * 100.0), 0, 100);
+ if (next == intValue) return;
+ intValue = next;
+ updateMessage();
+ onPreview.accept(intValue);
+ }
+
+ @Override
+ public void onRelease(MouseButtonEvent click) {
+ super.onRelease(click);
+ onCommit.accept(intValue);
+ }
+
+ @Override
+ public boolean keyPressed(KeyEvent input) {
+ boolean handled = super.keyPressed(input);
+ if (handled) onCommit.accept(intValue);
+ return handled;
+ }
+
+ private boolean insideClip(double mouseX, double mouseY) {
+ return mouseX >= clipLeft
+ && mouseY >= clipTop
+ && mouseX < clipRight
+ && mouseY < clipBottom;
+ }
+
+ @FunctionalInterface
+ interface TextFormatter {
+ Component apply(int value);
+ }
+}
diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/VpTextFieldWidget.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/VpTextFieldWidget.java
new file mode 100644
index 0000000..db04805
--- /dev/null
+++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/VpTextFieldWidget.java
@@ -0,0 +1,242 @@
+package com.github.squi2rel.vp.creation;
+
+import net.minecraft.client.gui.Font;
+import net.minecraft.client.gui.GuiGraphicsExtractor;
+import net.minecraft.client.input.CharacterEvent;
+import net.minecraft.client.input.KeyEvent;
+import net.minecraft.client.input.MouseButtonEvent;
+import net.minecraft.network.chat.Component;
+
+class VpTextFieldWidget extends FilteredEditBox {
+ private static final int PADDING_X = 4;
+ private static final int PADDING_Y = 4;
+
+ private final Font textRenderer;
+ private int frameX;
+ private int frameY;
+ private final int frameWidth;
+ private final int frameHeight;
+ private final VpUiTheme theme;
+ private int visibleStart;
+ private int selectionEnd;
+ private boolean clipped;
+ private int clipLeft;
+ private int clipTop;
+ private int clipRight;
+ private int clipBottom;
+
+ VpTextFieldWidget(Font textRenderer, int x, int y, int width, int height, Component message, VpUiTheme theme) {
+ super(textRenderer, x + PADDING_X, y + PADDING_Y, Math.max(1, width - PADDING_X * 2), textRenderer.lineHeight, message);
+ this.textRenderer = textRenderer;
+ this.frameX = x;
+ this.frameY = y;
+ this.frameWidth = Math.max(40, width);
+ this.frameHeight = Math.max(16, height);
+ this.theme = theme;
+ setBordered(false);
+ setTextColor(theme.primaryTextColor());
+ setTextColorUneditable(VpUiRenderer.blend(theme.secondaryTextColor(), theme.canvasBackgroundColor(), 0.42f));
+ }
+
+ VpTextFieldWidget clip(int left, int top, int right, int bottom) {
+ this.clipped = true;
+ this.clipLeft = left;
+ this.clipTop = top;
+ this.clipRight = right;
+ this.clipBottom = bottom;
+ return this;
+ }
+
+ @Override
+ public void setX(int x) {
+ frameX = x;
+ super.setX(x + PADDING_X);
+ }
+
+ @Override
+ public void setY(int y) {
+ frameY = y;
+ super.setY(y + PADDING_Y);
+ }
+
+ @Override
+ public void extractWidgetRenderState(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
+ int fill = VpUiRenderer.darken(theme.nodeBodyColor(), active ? 0.02f : 0.10f);
+ int border = isFocused() ? theme.accentColor() : theme.panelBorderColor();
+ if (!active) {
+ border = VpUiRenderer.blend(border, theme.canvasBackgroundColor(), 0.45f);
+ }
+ VpUiRenderer.drawBox(context, frameX, frameY, frameWidth, frameHeight, fill, border);
+ renderTextContent(context);
+ }
+
+ @Override
+ public void setValue(String text) {
+ super.setValue(text);
+ syncSelectionState();
+ }
+
+ @Override
+ public void insertText(String text) {
+ super.insertText(text);
+ syncSelectionState();
+ }
+
+ @Override
+ public void setCursorPosition(int selectionStart) {
+ super.setCursorPosition(selectionStart);
+ syncSelectionStart();
+ }
+
+ @Override
+ public void setHighlightPos(int selectionEnd) {
+ super.setHighlightPos(selectionEnd);
+ this.selectionEnd = clampIndex(selectionEnd);
+ updateVisibleStart(this.selectionEnd);
+ }
+
+ @Override
+ public boolean keyPressed(KeyEvent input) {
+ boolean handled = super.keyPressed(input);
+ if (handled) syncSelectionState();
+ return handled;
+ }
+
+ @Override
+ public boolean charTyped(CharacterEvent input) {
+ boolean handled = super.charTyped(input);
+ if (handled) syncSelectionState();
+ return handled;
+ }
+
+ @Override
+ public void onClick(MouseButtonEvent click, boolean doubleClick) {
+ super.onClick(click, doubleClick);
+ syncSelectionState();
+ }
+
+ @Override
+ public boolean isMouseOver(double mouseX, double mouseY) {
+ return visible
+ && mouseX >= frameX
+ && mouseY >= frameY
+ && mouseX < frameX + frameWidth
+ && mouseY < frameY + frameHeight
+ && (!clipped || insideClip(mouseX, mouseY));
+ }
+
+ private boolean insideClip(double mouseX, double mouseY) {
+ return mouseX >= clipLeft
+ && mouseY >= clipTop
+ && mouseX < clipRight
+ && mouseY < clipBottom;
+ }
+
+ private void renderTextContent(GuiGraphicsExtractor context) {
+ String text = getValue();
+ int cursor = clampIndex(getCursorPosition());
+ int safeVisibleStart = clampIndex(visibleStart);
+ String visibleText = textRenderer.plainSubstrByWidth(text.substring(safeVisibleStart), getInnerWidth());
+ int visibleEnd = Math.min(text.length(), safeVisibleStart + visibleText.length());
+ int innerX = getX();
+ int innerY = getY();
+ int right = innerX + getInnerWidth();
+ int textColor = active ? theme.primaryTextColor() : VpUiRenderer.blend(theme.secondaryTextColor(), theme.canvasBackgroundColor(), 0.42f);
+
+ context.enableScissor(innerX, frameY + 1, right, frameY + frameHeight - 1);
+ renderSelection(context, text, safeVisibleStart, visibleEnd, innerX, right);
+ drawText(context, visibleText, innerX, innerY, textColor);
+ renderCursor(context, text, cursor, safeVisibleStart, visibleEnd, innerX, innerY, right, textColor);
+ context.disableScissor();
+ }
+
+ private void renderSelection(GuiGraphicsExtractor context, String text, int visibleStart, int visibleEnd, int innerX, int right) {
+ if (!isFocused() || selectionEnd == getCursorPosition()) {
+ return;
+ }
+ int start = Math.min(clampIndex(getCursorPosition()), clampIndex(selectionEnd));
+ int end = Math.max(clampIndex(getCursorPosition()), clampIndex(selectionEnd));
+ int visibleSelectionStart = Math.max(visibleStart, Math.min(visibleEnd, start));
+ int visibleSelectionEnd = Math.max(visibleStart, Math.min(visibleEnd, end));
+ if (visibleSelectionEnd <= visibleSelectionStart) {
+ return;
+ }
+
+ int x1 = innerX + textRenderer.width(text.substring(visibleStart, visibleSelectionStart));
+ int x2 = innerX + textRenderer.width(text.substring(visibleStart, visibleSelectionEnd));
+ context.fill(Math.max(innerX, x1), frameY + 2, Math.min(right, x2), frameY + frameHeight - 2,
+ VpUiRenderer.blend(theme.accentColor(), theme.nodeBodyColor(), 0.24f));
+ }
+
+ private void renderCursor(GuiGraphicsExtractor context, String text, int cursor, int visibleStart, int visibleEnd, int innerX, int innerY, int right, int color) {
+ if (!isFocused() || (System.currentTimeMillis() / 530L) % 2L != 0L) {
+ return;
+ }
+ if (cursor < visibleStart || cursor > visibleEnd) {
+ return;
+ }
+ int cursorX = innerX + textRenderer.width(text.substring(visibleStart, cursor));
+ cursorX = Math.clamp(cursorX, innerX, right - 1);
+ context.fill(cursorX, frameY + 2, cursorX + 1, frameY + frameHeight - 2, color);
+ }
+
+ private void drawText(GuiGraphicsExtractor context, String text, int x, int y, int color) {
+ if (text.isEmpty()) {
+ return;
+ }
+ if (theme.textShadow()) {
+ context.text(textRenderer, text, x, y, color);
+ return;
+ }
+ context.text(textRenderer, text, x, y, color, false);
+ }
+
+ private void syncSelectionState() {
+ syncSelectionStart();
+ if (getHighlighted().isEmpty()) {
+ selectionEnd = getCursorPosition();
+ } else {
+ selectionEnd = inferSelectionEnd();
+ updateVisibleStart(selectionEnd);
+ }
+ }
+
+ private void syncSelectionStart() {
+ int cursor = clampIndex(getCursorPosition());
+ if (selectionEnd > getValue().length()) {
+ selectionEnd = cursor;
+ }
+ updateVisibleStart(cursor);
+ }
+
+ private int inferSelectionEnd() {
+ String text = getValue();
+ String selected = getHighlighted();
+ int cursor = clampIndex(getCursorPosition());
+ int length = selected.length();
+ if (cursor + length <= text.length() && text.substring(cursor, cursor + length).equals(selected)) {
+ return cursor + length;
+ }
+ if (cursor - length >= 0 && text.substring(cursor - length, cursor).equals(selected)) {
+ return cursor - length;
+ }
+ return cursor;
+ }
+
+ private void updateVisibleStart(int targetIndex) {
+ String text = getValue();
+ int target = clampIndex(targetIndex);
+ visibleStart = Math.clamp(visibleStart, 0, text.length());
+ if (target < visibleStart) {
+ visibleStart = target;
+ return;
+ }
+ while (visibleStart < target && textRenderer.width(text.substring(visibleStart, target)) > getInnerWidth()) {
+ visibleStart++;
+ }
+ }
+
+ private int clampIndex(int index) {
+ return Math.clamp(index, 0, getValue().length());
+ }
+}
diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/VpUiRenderer.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/VpUiRenderer.java
new file mode 100644
index 0000000..ca075c5
--- /dev/null
+++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/VpUiRenderer.java
@@ -0,0 +1,40 @@
+package com.github.squi2rel.vp.creation;
+
+import net.minecraft.client.gui.GuiGraphicsExtractor;
+
+final class VpUiRenderer {
+ private VpUiRenderer() {
+ }
+
+ static void drawBox(GuiGraphicsExtractor context, int x, int y, int width, int height, int fillColor, int borderColor) {
+ if (width <= 0 || height <= 0) {
+ return;
+ }
+ context.fill(x, y, x + width, y + height, fillColor);
+ context.outline(x, y, width, height, borderColor);
+ }
+
+ static int blend(int startColor, int endColor, float amount) {
+ int alpha = mixChannel((startColor >>> 24) & 0xFF, (endColor >>> 24) & 0xFF, amount);
+ int red = mixChannel((startColor >>> 16) & 0xFF, (endColor >>> 16) & 0xFF, amount);
+ int green = mixChannel((startColor >>> 8) & 0xFF, (endColor >>> 8) & 0xFF, amount);
+ int blue = mixChannel(startColor & 0xFF, endColor & 0xFF, amount);
+ return (alpha << 24) | (red << 16) | (green << 8) | blue;
+ }
+
+ static int darken(int color, float amount) {
+ return blend(color, 0xFF000000, amount);
+ }
+
+ static int brighten(int color, float amount) {
+ return blend(color, 0xFFFFFFFF, amount);
+ }
+
+ static int withAlpha(int color, int alpha) {
+ return (Math.clamp(alpha, 0, 255) << 24) | (color & 0x00FFFFFF);
+ }
+
+ private static int mixChannel(int start, int end, float amount) {
+ return Math.max(0, Math.min(255, Math.round(start + ((end - start) * amount))));
+ }
+}
diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/YouTubeAuthScreen.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/YouTubeAuthScreen.java
new file mode 100644
index 0000000..7ace412
--- /dev/null
+++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/creation/YouTubeAuthScreen.java
@@ -0,0 +1,167 @@
+package com.github.squi2rel.vp.creation;
+
+import com.github.squi2rel.vp.VideoPlayerClient;
+import com.github.squi2rel.vp.i18n.VpTexts;
+import java.util.List;
+import net.minecraft.ChatFormatting;
+import net.minecraft.client.gui.GuiGraphicsExtractor;
+import net.minecraft.client.gui.screens.Screen;
+import net.minecraft.network.chat.Component;
+import net.minecraft.util.FormattedCharSequence;
+
+public final class YouTubeAuthScreen extends Screen {
+ private static final VpUiTheme THEME = VpUiTheme.classic();
+ private static final int PANEL_WIDTH = 460;
+ private static final int PANEL_MIN_HEIGHT = 210;
+ private static final int CONTROL_HEIGHT = 20;
+ private static final int HINT_LINE_HEIGHT = 10;
+ private static final int HINT_TOP = 110;
+ private static final int HINT_BOTTOM_SPACE = 64;
+
+ private final Screen parent;
+ private VpTextFieldWidget cookiesFile;
+ private VpTextFieldWidget browserSpec;
+ private VpButtonWidget save;
+ private VpButtonWidget clear;
+ private VpButtonWidget close;
+ private Component status = Component.empty();
+
+ public YouTubeAuthScreen(Screen parent) {
+ super(VpTexts.tr("screen.videoplayer.youtube_auth", "YouTube Authentication"));
+ this.parent = parent;
+ }
+
+ @Override
+ protected void init() {
+ Layout layout = layout();
+ int fieldWidth = layout.panelWidth - 48;
+ cookiesFile = new VpTextFieldWidget(font, layout.left + 24, layout.top + 42, fieldWidth, CONTROL_HEIGHT,
+ VpTexts.tr("label.videoplayer.youtube_cookies_file", "Netscape cookie file"), THEME);
+ cookiesFile.setMaxLength(4096);
+ cookiesFile.setValue(currentCookiesFile());
+ browserSpec = new VpTextFieldWidget(font, layout.left + 24, layout.top + 80, fieldWidth, CONTROL_HEIGHT,
+ VpTexts.tr("label.videoplayer.youtube_browser", "Browser profile (yt-dlp)"), THEME);
+ browserSpec.setMaxLength(256);
+ browserSpec.setValue(currentBrowserSpec());
+ save = new VpButtonWidget(layout.left + 24, layout.buttonY(), 96, CONTROL_HEIGHT,
+ VpTexts.tr("button.videoplayer.save", "Save"), ignored -> saveValues(), THEME);
+ clear = new VpButtonWidget(layout.left + 128, layout.buttonY(), 96, CONTROL_HEIGHT,
+ VpTexts.tr("button.videoplayer.clear", "Clear"), ignored -> clearValues(), THEME);
+ close = new VpButtonWidget(layout.left + layout.panelWidth - 120, layout.buttonY(), 96, CONTROL_HEIGHT,
+ VpTexts.tr("button.videoplayer.close", "Close"), ignored -> onClose(), THEME);
+ addRenderableWidget(cookiesFile);
+ addRenderableWidget(browserSpec);
+ addRenderableWidget(save);
+ addRenderableWidget(clear);
+ addRenderableWidget(close);
+ }
+
+ @Override
+ public void onClose() {
+ if (minecraft != null) minecraft.gui.setScreen(parent);
+ }
+
+ @Override
+ public boolean isPauseScreen() {
+ return false;
+ }
+
+ @Override
+ public void extractRenderState(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
+ context.fill(0, 0, width, height, 0xB0000000);
+ Layout layout = layout();
+ context.fill(layout.left, layout.top, layout.left + layout.panelWidth, layout.top + layout.panelHeight, THEME.panelBackgroundColor());
+ context.outline(layout.left, layout.top, layout.panelWidth, layout.panelHeight, THEME.panelBorderColor());
+ context.centeredText(font, title, width / 2, layout.top + 8, THEME.primaryTextColor());
+ drawTrimmedLabel(context, VpTexts.tr("label.videoplayer.youtube_cookies_file", "Netscape cookie file"), layout.left + 24, layout.top + 30, layout.contentWidth);
+ drawTrimmedLabel(context, VpTexts.tr("label.videoplayer.youtube_browser", "Browser profile (yt-dlp)"), layout.left + 24, layout.top + 68, layout.contentWidth);
+ int hintY = layout.top + HINT_TOP;
+ hintY = drawWrappedLabel(context, layout.fileHintLines, layout.left + 24, hintY);
+ drawWrappedLabel(context, layout.serverHintLines, layout.left + 24, hintY + 4);
+ if (!status.getString().isBlank()) {
+ drawTrimmedLabel(context, status, layout.left + 24, layout.buttonY() - 16, layout.contentWidth);
+ }
+ super.extractRenderState(context, mouseX, mouseY, delta);
+ }
+
+ private void saveValues() {
+ if (VideoPlayerClient.config == null) return;
+ VideoPlayerClient.config.youtubeCookiesFile = cookiesFile.getValue().trim();
+ VideoPlayerClient.config.youtubeCookiesFromBrowser = browserSpec.getValue().trim();
+ VideoPlayerClient.saveConfig();
+ VideoPlayerClient.applyNativePlatformConfig();
+ status = VpTexts.tr("message.videoplayer.youtube_auth_saved", "YouTube authentication settings saved").withStyle(ChatFormatting.GREEN);
+ }
+
+ private void clearValues() {
+ cookiesFile.setValue("");
+ browserSpec.setValue("");
+ saveValues();
+ status = VpTexts.tr("message.videoplayer.youtube_auth_cleared", "YouTube authentication settings cleared").withStyle(ChatFormatting.GREEN);
+ }
+
+ private String currentCookiesFile() {
+ return VideoPlayerClient.config == null || VideoPlayerClient.config.youtubeCookiesFile == null
+ ? "" : VideoPlayerClient.config.youtubeCookiesFile;
+ }
+
+ private String currentBrowserSpec() {
+ return VideoPlayerClient.config == null || VideoPlayerClient.config.youtubeCookiesFromBrowser == null
+ ? "" : VideoPlayerClient.config.youtubeCookiesFromBrowser;
+ }
+
+ private Layout layout() {
+ int panelWidth = Math.min(PANEL_WIDTH, Math.max(260, width - 24));
+ int contentWidth = panelWidth - 48;
+ List fileHintLines = font.split(VpTexts.tr(
+ "hint.videoplayer.youtube_auth_file",
+ "Export a Netscape cookies.txt file from a signed-in browser. A cookie file takes priority; otherwise use a yt-dlp browser profile. Do not enter your password."
+ ), contentWidth);
+ List serverHintLines = font.split(VpTexts.tr(
+ "hint.videoplayer.youtube_auth_server",
+ "This setting applies only to this client. Configure server cookies separately for server-side streams and live playback."
+ ), contentWidth);
+ int desiredHeight = Math.max(PANEL_MIN_HEIGHT, HINT_BOTTOM_SPACE + HINT_TOP
+ + (fileHintLines.size() + serverHintLines.size()) * HINT_LINE_HEIGHT);
+ int maxHeight = Math.max(1, height - 16);
+ int panelHeight = Math.min(desiredHeight, maxHeight);
+ int lineCapacity = Math.max(0, (panelHeight - HINT_TOP - HINT_BOTTOM_SPACE) / HINT_LINE_HEIGHT);
+ int fileLines = Math.min(fileHintLines.size(), Math.max(0, (lineCapacity + 1) / 2));
+ int serverLines = Math.min(serverHintLines.size(), Math.max(0, lineCapacity - fileLines));
+ int remaining = lineCapacity - fileLines - serverLines;
+ if (remaining > 0) {
+ int extraFile = Math.min(remaining, fileHintLines.size() - fileLines);
+ fileLines += extraFile;
+ remaining -= extraFile;
+ serverLines += Math.min(remaining, serverHintLines.size() - serverLines);
+ }
+ int top = Math.max(8, (height - panelHeight) / 2);
+ return new Layout(panelWidth, panelHeight, contentWidth, (width - panelWidth) / 2, top,
+ fileHintLines.subList(0, fileLines), serverHintLines.subList(0, serverLines));
+ }
+
+ private int drawWrappedLabel(GuiGraphicsExtractor context, List lines, int x, int y) {
+ int currentY = y;
+ for (FormattedCharSequence line : lines) {
+ context.text(font, line, x, currentY, THEME.secondaryTextColor());
+ currentY += HINT_LINE_HEIGHT;
+ }
+ return currentY;
+ }
+
+ private void drawTrimmedLabel(GuiGraphicsExtractor context, Component text, int x, int y, int maxWidth) {
+ Component visible = Component.literal(font.substrByWidth(text, Math.max(1, maxWidth)).getString());
+ drawLabel(context, visible, x, y);
+ }
+
+ private void drawLabel(GuiGraphicsExtractor context, Component text, int x, int y) {
+ context.text(font, text, x, y, THEME.secondaryTextColor());
+ }
+
+ private record Layout(int panelWidth, int panelHeight, int contentWidth, int left, int top,
+ List fileHintLines, List serverHintLines) {
+ private int buttonY() {
+ return top + panelHeight - 26;
+ }
+ }
+}
diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/danmaku/BiliAuthStore.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/danmaku/BiliAuthStore.java
new file mode 100644
index 0000000..8b2bd2f
--- /dev/null
+++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/danmaku/BiliAuthStore.java
@@ -0,0 +1,197 @@
+package com.github.squi2rel.vp.danmaku;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonParseException;
+import net.fabricmc.loader.api.FabricLoader;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static com.github.squi2rel.vp.VideoPlayerMain.LOGGER;
+
+public final class BiliAuthStore {
+ private static final Gson GSON = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create();
+ private static final Path PATH = FabricLoader.getInstance().getConfigDir().resolve("videoplayer").resolve("auth.json");
+
+ private static AuthData cache;
+ private static boolean loaded;
+
+ private BiliAuthStore() {
+ }
+
+ public static synchronized AuthData snapshot() {
+ ensureLoaded();
+ return copy(cache);
+ }
+
+ public static synchronized String cookie() {
+ ensureLoaded();
+ return sanitize(cache.bilibiliCookie);
+ }
+
+ public static synchronized void replaceCookieAndResetAuth(String cookie) {
+ AuthData next = snapshotMutable();
+ next.bilibiliCookie = sanitize(cookie);
+ next.bilibiliRefreshToken = "";
+ next.bilibiliLoginTimestamp = 0L;
+ next.bilibiliRefreshTimestamp = 0L;
+ next.bilibiliLastRefreshCheckTimestamp = 0L;
+ next.bilibiliMid = 0L;
+ next.bilibiliUserName = "";
+ persist(next);
+ }
+
+ public static synchronized void updateCookie(String cookie) {
+ AuthData next = snapshotMutable();
+ next.bilibiliCookie = sanitize(cookie);
+ persist(next);
+ }
+
+ public static synchronized void saveLogin(String cookie, String refreshToken, long loginTimestamp, long mid, String userName) {
+ AuthData next = snapshotMutable();
+ long timestamp = Math.max(0L, loginTimestamp);
+ next.bilibiliCookie = sanitize(cookie);
+ next.bilibiliRefreshToken = sanitize(refreshToken);
+ next.bilibiliLoginTimestamp = timestamp;
+ next.bilibiliRefreshTimestamp = 0L;
+ next.bilibiliLastRefreshCheckTimestamp = timestamp;
+ next.bilibiliMid = Math.max(0L, mid);
+ next.bilibiliUserName = sanitize(userName);
+ persist(next);
+ }
+
+ public static synchronized void saveRefresh(String cookie, String refreshToken, long refreshTimestamp, long mid, String userName) {
+ AuthData next = snapshotMutable();
+ long timestamp = Math.max(0L, refreshTimestamp);
+ next.bilibiliCookie = sanitize(cookie);
+ next.bilibiliRefreshToken = sanitize(refreshToken);
+ if (next.bilibiliLoginTimestamp <= 0L) next.bilibiliLoginTimestamp = timestamp;
+ next.bilibiliRefreshTimestamp = timestamp;
+ next.bilibiliLastRefreshCheckTimestamp = timestamp;
+ next.bilibiliMid = Math.max(0L, mid);
+ next.bilibiliUserName = sanitize(userName);
+ persist(next);
+ }
+
+ public static synchronized void updateRefreshCheck(long timestamp) {
+ AuthData next = snapshotMutable();
+ next.bilibiliLastRefreshCheckTimestamp = Math.max(0L, timestamp);
+ persist(next);
+ }
+
+ public static synchronized void clear() {
+ persist(new AuthData());
+ }
+
+ static synchronized Map cookieMap() {
+ return cookieMap(cookie());
+ }
+
+ static Map cookieMap(String cookie) {
+ LinkedHashMap result = new LinkedHashMap<>();
+ if (cookie == null || cookie.isBlank()) return result;
+ for (String part : cookie.split(";")) {
+ String trimmed = part.trim();
+ if (trimmed.isEmpty()) continue;
+ int index = trimmed.indexOf('=');
+ if (index <= 0) continue;
+ String key = trimmed.substring(0, index).trim();
+ String value = trimmed.substring(index + 1).trim();
+ if (key.isEmpty()) continue;
+ result.put(key, value);
+ }
+ return result;
+ }
+
+ static String formatCookie(Map values) {
+ if (values == null || values.isEmpty()) return "";
+ StringBuilder builder = new StringBuilder();
+ for (Map.Entry entry : values.entrySet()) {
+ String key = entry.getKey();
+ if (key == null || key.isBlank()) continue;
+ if (builder.length() > 0) builder.append("; ");
+ builder.append(key.trim());
+ builder.append('=');
+ builder.append(sanitize(entry.getValue()));
+ }
+ return builder.toString();
+ }
+
+ private static AuthData snapshotMutable() {
+ ensureLoaded();
+ return copy(cache);
+ }
+
+ private static void ensureLoaded() {
+ if (loaded) return;
+ cache = read();
+ loaded = true;
+ }
+
+ private static AuthData read() {
+ if (!Files.exists(PATH)) return new AuthData();
+ try {
+ String raw = Files.readString(PATH);
+ AuthData data = GSON.fromJson(raw, AuthData.class);
+ return normalize(data == null ? new AuthData() : data);
+ } catch (IOException | JsonParseException e) {
+ LOGGER.warn("Failed to read Bilibili auth store", e);
+ return new AuthData();
+ }
+ }
+
+ private static void persist(AuthData data) {
+ AuthData next = normalize(copy(data));
+ try {
+ Files.createDirectories(PATH.getParent());
+ Files.writeString(PATH, GSON.toJson(next));
+ cache = next;
+ loaded = true;
+ } catch (IOException e) {
+ LOGGER.warn("Failed to save Bilibili auth store", e);
+ throw new RuntimeException("Failed to save Bilibili auth store", e);
+ }
+ }
+
+ private static AuthData normalize(AuthData data) {
+ if (data.bilibiliCookie == null) data.bilibiliCookie = "";
+ if (data.bilibiliRefreshToken == null) data.bilibiliRefreshToken = "";
+ if (data.bilibiliUserName == null) data.bilibiliUserName = "";
+ if (data.bilibiliLoginTimestamp < 0L) data.bilibiliLoginTimestamp = 0L;
+ if (data.bilibiliRefreshTimestamp < 0L) data.bilibiliRefreshTimestamp = 0L;
+ if (data.bilibiliLastRefreshCheckTimestamp < 0L) data.bilibiliLastRefreshCheckTimestamp = 0L;
+ if (data.bilibiliMid < 0L) data.bilibiliMid = 0L;
+ return data;
+ }
+
+ private static AuthData copy(AuthData data) {
+ AuthData copy = new AuthData();
+ if (data == null) return copy;
+ copy.bilibiliCookie = data.bilibiliCookie;
+ copy.bilibiliRefreshToken = data.bilibiliRefreshToken;
+ copy.bilibiliLoginTimestamp = data.bilibiliLoginTimestamp;
+ copy.bilibiliRefreshTimestamp = data.bilibiliRefreshTimestamp;
+ copy.bilibiliLastRefreshCheckTimestamp = data.bilibiliLastRefreshCheckTimestamp;
+ copy.bilibiliMid = data.bilibiliMid;
+ copy.bilibiliUserName = data.bilibiliUserName;
+ return copy;
+ }
+
+ private static String sanitize(String value) {
+ return value == null ? "" : value.trim().replace("\r", "").replace("\n", "");
+ }
+
+ public static final class AuthData {
+ public String bilibiliCookie = "";
+ public String bilibiliRefreshToken = "";
+ public long bilibiliLoginTimestamp = 0L;
+ public long bilibiliRefreshTimestamp = 0L;
+ public long bilibiliLastRefreshCheckTimestamp = 0L;
+ public long bilibiliMid = 0L;
+ public String bilibiliUserName = "";
+ }
+}
diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/danmaku/ClientDanmakuController.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/danmaku/ClientDanmakuController.java
new file mode 100644
index 0000000..6dbe075
--- /dev/null
+++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/danmaku/ClientDanmakuController.java
@@ -0,0 +1,607 @@
+package com.github.squi2rel.vp.danmaku;
+
+import com.github.squi2rel.vp.VideoPlayerClient;
+import com.github.squi2rel.vp.provider.VideoInfo;
+import com.github.squi2rel.vp.video.ClientVideoScreen;
+import com.github.squi2rel.vp.video.ScreenMetadata;
+import com.github.squi2rel.vp.video.ScreenSurface;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Objects;
+import java.util.Random;
+import java.util.Set;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.CompletableFuture;
+import net.minecraft.client.Minecraft;
+
+import static com.github.squi2rel.vp.VideoPlayerMain.LOGGER;
+
+public final class ClientDanmakuController {
+ public static final float VIRTUAL_WIDTH = 640.0f;
+ public static final float VIRTUAL_HEIGHT = 360.0f;
+ private static final int SEGMENT_MS = 360_000;
+ private static final int MAX_ACTIVE = 512;
+ private static final int MAX_LIVE_INCOMING = 1024;
+ private static final int MAX_LIVE_DRAIN_PER_TICK = 128;
+ private static final int FIXED_LANES = 4;
+ private static final float LANE_HEIGHT = 18.0f;
+ private static final long FIXED_DURATION_MS = 4000;
+ private static final long BASE_MIN_ROLLING_DURATION_MS = 6500;
+ private static final long BASE_MAX_ROLLING_DURATION_MS = 9000;
+ private static final long MIN_ROLLING_DURATION_MS = 3500;
+ private static final long MAX_ROLLING_DURATION_MS = 14000;
+ private static final long ROLLING_LANE_EMIT_DELAY_MS = 200;
+ private static final float[] SPEED_MULTIPLIERS = {1.6f, 1.25f, 1.0f, 0.75f, 0.55f};
+ private static final int DENSITY_NORMAL = 0;
+ private static final int DENSITY_MORE = 1;
+ private static final int DENSITY_OVERLAP = 2;
+ private static final Random RANDOM = new Random();
+
+ private final ClientVideoScreen screen;
+ private final ArrayBlockingQueue liveIncoming = new ArrayBlockingQueue<>(MAX_LIVE_INCOMING);
+ private final ArrayList active = new ArrayList<>();
+ private final ArrayList vodEntries = new ArrayList<>();
+ private final Set vodEntryKeys = new HashSet<>();
+ private final Set emittedKeys = new HashSet<>();
+ private final Set loadedSegments = new HashSet<>();
+ private final Set loadingSegments = new HashSet<>();
+
+ private static Boolean enabledOverride;
+ private VideoInfo currentInfo;
+ private String currentInfoKey = "";
+ private BiliBiliSourceInfo sourceInfo;
+ private CompletableFuture sourceTask;
+ private BiliLiveDanmakuClient liveClient;
+ private YouTubeLiveChatClient youtubeLiveClient;
+ private boolean youtubeLiveClientUnavailable;
+ private volatile long liveGeneration;
+ private int nextVodIndex;
+ private long lastProgress = -1;
+ private long animationTime;
+ private long lastWallTime;
+ private int topLaneCursor;
+ private int bottomLaneCursor;
+
+ public ClientDanmakuController(ClientVideoScreen screen) {
+ this.screen = screen;
+ }
+
+ public static boolean canRenderOn(ClientVideoScreen displayScreen) {
+ if (displayScreen == null || displayScreen.surface == ScreenSurface.SPHERE_360) return false;
+ ClientVideoScreen playback = displayScreen.getScreen();
+ return playback != null
+ && playback.surface != ScreenSurface.SPHERE_360
+ && (BiliBiliSourceRegistry.canResolve(playback.currentPlaybackInfo())
+ || YouTubeLiveChatClient.canResolve(playback.currentPlaybackInfo()));
+ }
+
+ public static boolean isEnabledOn(ClientVideoScreen displayScreen) {
+ if (!canRenderOn(displayScreen)) return false;
+ ClientVideoScreen playback = displayScreen.getScreen();
+ return playback != null && playback.danmaku().enabled() && isScreenEnabled(displayScreen);
+ }
+
+ public static boolean toggleOn(ClientVideoScreen displayScreen) {
+ ClientVideoScreen playback = displayScreen == null ? null : displayScreen.getScreen();
+ if (playback == null) return toggleGlobal();
+ return playback.danmaku().toggle();
+ }
+
+ public static boolean isGlobalEnabled() {
+ return enabledOverride == null ? VideoPlayerClient.config.danmakuDefaultEnabled : enabledOverride;
+ }
+
+ public static boolean toggleGlobal() {
+ enabledOverride = !isGlobalEnabled();
+ if (!enabledOverride) stopAllNetworkAndClear();
+ return enabledOverride;
+ }
+
+ public static boolean isScreenEnabled(ClientVideoScreen displayScreen) {
+ return displayScreen != null
+ && (displayScreen.metadata == null || displayScreen.metadata.getBool(ScreenMetadata.KEY_DANMAKU_ENABLED, true));
+ }
+
+ public boolean enabled() {
+ return isGlobalEnabled();
+ }
+
+ public boolean toggle() {
+ return toggleGlobal();
+ }
+
+ private static void stopAllNetworkAndClear() {
+ for (ClientVideoScreen screen : VideoPlayerClient.screens) {
+ screen.danmaku().stopNetworkAndClear();
+ }
+ }
+
+ public float canvasWidth() {
+ try {
+ float aspect = screen.geometry().width() / Math.max(1.0f, screen.geometry().height());
+ if (Float.isFinite(aspect) && aspect > 0) {
+ return Math.clamp(VIRTUAL_HEIGHT * aspect, 240.0f, 1280.0f);
+ }
+ } catch (IllegalArgumentException ignored) {
+ }
+ return VIRTUAL_WIDTH;
+ }
+
+ public float canvasHeight() {
+ return VIRTUAL_HEIGHT;
+ }
+
+ public void update() {
+ VideoInfo info = screen.currentPlaybackInfo();
+ String infoKey = infoKey(info);
+ if (!Objects.equals(infoKey, currentInfoKey)) {
+ resetForInfo(info, infoKey);
+ }
+
+ advanceAnimationClock();
+ updateActive();
+ boolean biliSource = BiliBiliSourceRegistry.canResolve(info);
+ boolean youtubeLiveSource = YouTubeLiveChatClient.canResolve(info);
+ if (screen.surface == ScreenSurface.SPHERE_360 || info == null || !enabled()
+ || (!biliSource && !youtubeLiveSource)) {
+ stopNetworkAndClear();
+ return;
+ }
+
+ if (youtubeLiveSource) {
+ stopLiveClient();
+ ensureYouTubeLiveClient(info);
+ drainLiveIncoming();
+ return;
+ }
+
+ stopYouTubeLiveClient();
+
+ updateSourceTask(info);
+ if (sourceInfo == null) return;
+
+ if (sourceInfo.live()) {
+ ensureLiveClient();
+ drainLiveIncoming();
+ } else {
+ stopLiveClient();
+ updateVod();
+ }
+ }
+
+ public void seek(long progress) {
+ active.clear();
+ emittedKeys.clear();
+ nextVodIndex = lowerBound(Math.max(0, progress - 1000));
+ lastProgress = progress;
+ resetLanes();
+ }
+
+ public void stop() {
+ stopNetworkAndClear();
+ resetForInfo(null, "");
+ }
+
+ public List renderables() {
+ return renderables(canvasWidth(), canvasHeight());
+ }
+
+ public List renderables(float canvasWidth, float canvasHeight) {
+ ArrayList result = new ArrayList<>(active.size());
+ canvasWidth = Math.max(1.0f, canvasWidth);
+ canvasHeight = Math.max(1.0f, canvasHeight);
+ for (ActiveDanmaku item : active) {
+ if (blockedByLocalSettings(item.mode(), item.color())) continue;
+ long duration = durationMs(item, canvasWidth);
+ long elapsed = animationTime - item.startTime();
+ if (elapsed < 0 || elapsed > duration) continue;
+ float x;
+ float y;
+ if (item.rolling()) {
+ if (item.lane() >= rollingLaneCount(item.height())) continue;
+ float travel = canvasWidth + item.width();
+ float progress = Math.clamp(elapsed / (float) duration, 0.0f, 1.0f);
+ x = item.leftToRight() ? -item.width() + progress * travel : canvasWidth - progress * travel;
+ y = 6.0f + item.lane() * LANE_HEIGHT;
+ } else if (item.fixedBottom()) {
+ x = Math.max(4.0f, (canvasWidth - item.width()) * 0.5f);
+ y = canvasHeight - 8.0f - (item.lane() + 1) * LANE_HEIGHT;
+ } else {
+ x = Math.max(4.0f, (canvasWidth - item.width()) * 0.5f);
+ y = 6.0f + item.lane() * LANE_HEIGHT;
+ }
+ if (x + item.width() < -0.5f || x > canvasWidth + 0.5f || y + item.height() < 0 || y > canvasHeight) continue;
+ result.add(new RenderableDanmaku(item.text(), x, y, item.scale(), item.color(), item.width(), item.height(), !item.rolling()));
+ }
+ return result;
+ }
+
+ private void resetForInfo(VideoInfo info, String infoKey) {
+ liveGeneration++;
+ currentInfo = info;
+ currentInfoKey = infoKey;
+ sourceInfo = null;
+ if (sourceTask != null) sourceTask.cancel(true);
+ sourceTask = null;
+ stopLiveClient();
+ stopYouTubeLiveClient();
+ youtubeLiveClientUnavailable = false;
+ active.clear();
+ liveIncoming.clear();
+ vodEntries.clear();
+ vodEntryKeys.clear();
+ emittedKeys.clear();
+ loadedSegments.clear();
+ loadingSegments.clear();
+ nextVodIndex = 0;
+ lastProgress = -1;
+ animationTime = 0;
+ lastWallTime = 0;
+ resetLanes();
+ }
+
+ private void updateSourceTask(VideoInfo info) {
+ if (sourceInfo != null) return;
+ if (sourceTask == null) {
+ sourceTask = BiliBiliSourceRegistry.resolve(info);
+ return;
+ }
+ if (!sourceTask.isDone()) return;
+ try {
+ sourceInfo = sourceTask.get();
+ } catch (Exception e) {
+ LOGGER.warn("Failed to resolve Bilibili danmaku source", e);
+ } finally {
+ sourceTask = null;
+ }
+ }
+
+ private void updateVod() {
+ long progress = playbackProgress();
+ if (progress < 0) return;
+ if (lastProgress >= 0 && progress < lastProgress - 1500) {
+ seek(progress);
+ }
+ lastProgress = progress;
+ int segment = (int) (progress / SEGMENT_MS) + 1;
+ loadSegment(segment);
+ loadSegment(segment + 1);
+ enqueueVodDue(progress);
+ }
+
+ private long playbackProgress() {
+ if (screen.player == null) return -1;
+ long progress = screen.player.getProgress();
+ if (progress >= 0) return progress;
+ return Math.max(0, System.currentTimeMillis() - screen.getStartTime());
+ }
+
+ private void loadSegment(int segment) {
+ if (sourceInfo == null || !sourceInfo.vod() || segment <= 0 || loadedSegments.contains(segment) || loadingSegments.contains(segment)) return;
+ String expectedInfoKey = currentInfoKey;
+ loadingSegments.add(segment);
+ BiliVodDanmakuFetcher.fetchSegment(sourceInfo, segment).thenAccept(entries -> Minecraft.getInstance().execute(() -> {
+ if (!Objects.equals(expectedInfoKey, currentInfoKey)) return;
+ loadingSegments.remove(segment);
+ loadedSegments.add(segment);
+ addVodEntries(entries);
+ })).exceptionally(e -> {
+ Minecraft.getInstance().execute(() -> {
+ if (!Objects.equals(expectedInfoKey, currentInfoKey)) return;
+ loadingSegments.remove(segment);
+ loadedSegments.add(segment);
+ });
+ LOGGER.warn("Failed to fetch Bilibili danmaku segment {}", segment, e);
+ return null;
+ });
+ }
+
+ private void addVodEntries(List entries) {
+ if (entries == null || entries.isEmpty()) return;
+ for (DanmakuEntry entry : entries) {
+ if (entry == null || !entry.renderable()) continue;
+ if (vodEntryKeys.add(entry.key())) vodEntries.add(entry);
+ }
+ vodEntries.sort(Comparator.comparingLong(DanmakuEntry::progressMs));
+ if (lastProgress >= 0) nextVodIndex = Math.min(nextVodIndex, lowerBound(Math.max(0, lastProgress - 1000)));
+ }
+
+ private void enqueueVodDue(long progress) {
+ while (nextVodIndex < vodEntries.size()) {
+ DanmakuEntry entry = vodEntries.get(nextVodIndex);
+ if (entry.progressMs() > progress + 120) break;
+ nextVodIndex++;
+ if (entry.progressMs() < progress - 1000) continue;
+ if (emittedKeys.add(entry.key())) enqueue(entry);
+ }
+ }
+
+ private int lowerBound(long progress) {
+ int left = 0;
+ int right = vodEntries.size();
+ while (left < right) {
+ int mid = (left + right) >>> 1;
+ if (vodEntries.get(mid).progressMs() < progress) left = mid + 1;
+ else right = mid;
+ }
+ return left;
+ }
+
+ private void ensureLiveClient() {
+ if (liveClient != null) return;
+ long generation = liveGeneration;
+ liveClient = new BiliLiveDanmakuClient(sourceInfo, entry -> offerLive(generation, entry));
+ liveClient.start();
+ }
+
+ private void drainLiveIncoming() {
+ LiveIncoming incoming;
+ int drained = 0;
+ while (drained++ < MAX_LIVE_DRAIN_PER_TICK && (incoming = liveIncoming.poll()) != null) {
+ if (incoming.generation() == liveGeneration) enqueue(incoming.entry());
+ }
+ }
+
+ private void ensureYouTubeLiveClient(VideoInfo info) {
+ if (youtubeLiveClient != null || youtubeLiveClientUnavailable) return;
+ try {
+ long generation = liveGeneration;
+ youtubeLiveClient = new YouTubeLiveChatClient(info, entry -> offerLive(generation, entry));
+ youtubeLiveClient.start();
+ } catch (RuntimeException e) {
+ youtubeLiveClientUnavailable = true;
+ LOGGER.warn("Failed to prepare YouTube live chat", e);
+ }
+ }
+
+ private void offerLive(long generation, DanmakuEntry entry) {
+ if (entry != null && generation == liveGeneration) {
+ liveIncoming.offer(new LiveIncoming(generation, entry));
+ }
+ }
+
+ private void enqueue(DanmakuEntry entry) {
+ if (entry == null || !entry.renderable()) return;
+ if (blockedByLocalSettings(entry)) return;
+ spawn(entry);
+ }
+
+ private boolean spawn(DanmakuEntry entry) {
+ if (entry == null) return true;
+ float scale = entry.scale() * scaleMultiplier();
+ String text = entry.content();
+ float width = DanmakuTextLayoutCache.measureWidth(text, scale);
+ float height = DanmakuTextLayoutCache.measureHeight(scale);
+ int lane;
+ if (entry.fixedTop()) {
+ lane = fixedLane(false);
+ if (lane < 0) return false;
+ topLaneCursor++;
+ } else if (entry.fixedBottom()) {
+ lane = fixedLane(true);
+ if (lane < 0) return false;
+ bottomLaneCursor++;
+ } else {
+ lane = rollingLane(height, width, entry.leftToRight());
+ if (lane < 0) return false;
+ }
+ if (active.size() >= MAX_ACTIVE) active.removeFirst();
+ active.add(new ActiveDanmaku(text, entry.mode(), entry.argb(), scale, width, height, lane, animationTime));
+ return true;
+ }
+
+ private long rollingDuration(float canvasWidth, float width) {
+ float widthContribution = Math.min(width, canvasWidth * 0.5f);
+ long base = Math.clamp(Math.round((canvasWidth + widthContribution) * 8.0f), BASE_MIN_ROLLING_DURATION_MS, BASE_MAX_ROLLING_DURATION_MS);
+ return Math.clamp(Math.round(base * speedMultiplier()), MIN_ROLLING_DURATION_MS, MAX_ROLLING_DURATION_MS);
+ }
+
+ private long durationMs(ActiveDanmaku item, float canvasWidth) {
+ return item.rolling() ? rollingDuration(canvasWidth, item.width()) : FIXED_DURATION_MS;
+ }
+
+ private float speedMultiplier() {
+ int preset = VideoPlayerClient.config == null ? 2 : Math.clamp(VideoPlayerClient.config.danmakuSpeedPreset, 0, SPEED_MULTIPLIERS.length - 1);
+ return SPEED_MULTIPLIERS[preset];
+ }
+
+ private float scaleMultiplier() {
+ int percent = VideoPlayerClient.config == null ? 100 : Math.clamp(VideoPlayerClient.config.danmakuScalePercent, 50, 170);
+ return percent / 100.0f;
+ }
+
+ private int densityPreset() {
+ if (VideoPlayerClient.config == null || VideoPlayerClient.config.danmakuRollingRangePercent != 100) return DENSITY_NORMAL;
+ return Math.clamp(VideoPlayerClient.config.danmakuDensityPreset, DENSITY_NORMAL, DENSITY_OVERLAP);
+ }
+
+ private int rollingLaneCount(float height) {
+ float usableHeight = rollingRangeHeight() - 12.0f - height;
+ return Math.max(1, (int) Math.floor(usableHeight / LANE_HEIGHT) + 1);
+ }
+
+ private float rollingRangeHeight() {
+ int percent = VideoPlayerClient.config == null ? 50 : VideoPlayerClient.config.danmakuRollingRangePercent;
+ percent = switch (percent) {
+ case 25, 50, 75, 100 -> percent;
+ default -> 50;
+ };
+ if (VideoPlayerClient.config != null && VideoPlayerClient.config.danmakuBottomGuard) {
+ percent = Math.min(percent, 85);
+ }
+ return canvasHeight() * percent / 100.0f;
+ }
+
+ private int fixedLane(boolean bottom) {
+ boolean[] occupied = new boolean[FIXED_LANES];
+ for (ActiveDanmaku item : active) {
+ if (item.rolling() || item.fixedBottom() != bottom) continue;
+ long elapsed = animationTime - item.startTime();
+ if (elapsed >= 0 && elapsed <= durationMs(item, canvasWidth()) && item.lane() >= 0 && item.lane() < occupied.length) {
+ occupied[item.lane()] = true;
+ }
+ }
+ int cursor = bottom ? bottomLaneCursor : topLaneCursor;
+ for (int i = 0; i < FIXED_LANES; i++) {
+ int lane = Math.floorMod(cursor + i, FIXED_LANES);
+ if (!occupied[lane]) return lane;
+ }
+ return -1;
+ }
+
+ private int rollingLane(float height, float width, boolean leftToRight) {
+ int laneCount = rollingLaneCount(height);
+ int density = densityPreset();
+ for (int i = 0; i < laneCount; i++) {
+ int lane = i;
+ if (!rollingLaneEmitDelayed(lane) && !rollingLaneBlocked(lane, width, leftToRight, density)) {
+ return lane;
+ }
+ }
+ if (density == DENSITY_OVERLAP) {
+ int available = 0;
+ for (int lane = 0; lane < laneCount; lane++) {
+ if (!rollingLaneEmitDelayed(lane)) available++;
+ }
+ if (available > 0) {
+ int selected = RANDOM.nextInt(available);
+ for (int lane = 0; lane < laneCount; lane++) {
+ if (!rollingLaneEmitDelayed(lane) && selected-- == 0) return lane;
+ }
+ }
+ }
+ return -1;
+ }
+
+ private boolean rollingLaneEmitDelayed(int lane) {
+ for (ActiveDanmaku item : active) {
+ if (!item.rolling() || item.lane() != lane) continue;
+ long elapsed = animationTime - item.startTime();
+ if (elapsed >= 0 && elapsed < ROLLING_LANE_EMIT_DELAY_MS) return true;
+ }
+ return false;
+ }
+
+ private boolean rollingLaneBlocked(int lane, float width, boolean leftToRight, int density) {
+ float canvasWidth = canvasWidth();
+ float gap = density == DENSITY_MORE || density == DENSITY_OVERLAP
+ ? Math.max(28.0f, width * 0.25f)
+ : Math.max(72.0f, width * 0.75f);
+ for (ActiveDanmaku item : active) {
+ if (!item.rolling() || item.lane() != lane) continue;
+ long duration = durationMs(item, canvasWidth);
+ long elapsed = animationTime - item.startTime();
+ if (elapsed < 0 || elapsed > duration) continue;
+ float x = rollingX(item, elapsed, duration, canvasWidth);
+ if (leftToRight) {
+ if (x < gap) return true;
+ } else if (x + item.width() > canvasWidth - gap) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static float rollingX(ActiveDanmaku item, long elapsed, long duration, float canvasWidth) {
+ float travel = canvasWidth + item.width();
+ float progress = Math.clamp(elapsed / (float) duration, 0.0f, 1.0f);
+ return item.leftToRight() ? -item.width() + progress * travel : canvasWidth - progress * travel;
+ }
+
+ private void advanceAnimationClock() {
+ long now = System.currentTimeMillis();
+ if (lastWallTime == 0) {
+ lastWallTime = now;
+ return;
+ }
+ long delta = Math.clamp(now - lastWallTime, 0, 100);
+ lastWallTime = now;
+ if (screen.player == null || !screen.player.isPaused()
+ || (sourceInfo != null && sourceInfo.live()) || youtubeLiveClient != null) {
+ animationTime += delta;
+ }
+ }
+
+ private void updateActive() {
+ Iterator