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/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