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

- Build + Build Minecraft 1.21.11 Java 21 GPL-3.0 license 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 iterator = active.iterator(); + while (iterator.hasNext()) { + ActiveDanmaku item = iterator.next(); + if (blockedByLocalSettings(item.mode(), item.color()) || animationTime - item.startTime() > durationMs(item, canvasWidth())) { + iterator.remove(); + } + } + } + + private static boolean blockedByLocalSettings(DanmakuEntry entry) { + return entry != null && blockedByLocalSettings(entry.mode(), entry.argb()); + } + + private static boolean blockedByLocalSettings(int mode, int color) { + if (VideoPlayerClient.config == null) return false; + boolean rolling = mode == 1 || mode == 2 || mode == 3 || mode == 6; + boolean fixed = mode == 4 || mode == 5; + if (VideoPlayerClient.config.danmakuBlockRolling && rolling) return true; + if (VideoPlayerClient.config.danmakuBlockFixed && fixed) return true; + if (VideoPlayerClient.config.danmakuBottomGuard && mode == 4) return true; + return VideoPlayerClient.config.danmakuBlockColored && (color & 0x00FFFFFF) != 0x00FFFFFF; + } + + private void stopNetworkAndClear() { + liveGeneration++; + stopLiveClient(); + stopYouTubeLiveClient(); + if (sourceTask != null) { + sourceTask.cancel(true); + sourceTask = null; + } + active.clear(); + liveIncoming.clear(); + } + + private void stopLiveClient() { + if (liveClient != null) { + liveClient.stop(); + liveClient = null; + } + } + + private void stopYouTubeLiveClient() { + if (youtubeLiveClient != null) { + youtubeLiveClient.stop(); + youtubeLiveClient = null; + } + } + + private void resetLanes() { + topLaneCursor = 0; + bottomLaneCursor = 0; + } + + private String infoKey(VideoInfo info) { + if (info == null) return ""; + return (info.rawPath() == null ? "" : info.rawPath()) + "|" + (info.path() == null ? "" : info.path()); + } + + public record RenderableDanmaku(String text, float x, float y, float scale, int color, float width, float height, boolean fixed) { + } + + private record ActiveDanmaku(String text, int mode, int color, float scale, float width, float height, int lane, long startTime) { + boolean rolling() { + return mode == 1 || mode == 2 || mode == 3 || mode == 6; + } + + boolean leftToRight() { + return mode == 6; + } + + boolean fixedBottom() { + return mode == 4; + } + } + + private record LiveIncoming(long generation, DanmakuEntry entry) { + } +} diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/danmaku/ClientDanmakuRenderer.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/danmaku/ClientDanmakuRenderer.java new file mode 100644 index 0000000..e847666 --- /dev/null +++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/danmaku/ClientDanmakuRenderer.java @@ -0,0 +1,1175 @@ +package com.github.squi2rel.vp.danmaku; + +import com.github.squi2rel.vp.ScreenRenderer; +import com.github.squi2rel.vp.VideoPlayerClient; +import com.github.squi2rel.vp.mixin.client.DrawContextAccessor; +import com.github.squi2rel.vp.render.WorldRenderBatch; +import com.mojang.blaze3d.pipeline.RenderPipeline; +import com.mojang.blaze3d.systems.RenderSystem; +import com.mojang.blaze3d.textures.FilterMode; +import com.mojang.blaze3d.textures.GpuTextureView; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import com.github.squi2rel.vp.video.ClientVideoScreen; +import com.github.squi2rel.vp.video.ScreenGeometry; +import com.github.squi2rel.vp.video.ScreenMetadata; +import com.github.squi2rel.vp.video.ScreenSurface; +import org.joml.Matrix3x2f; +import org.joml.Matrix4f; +import org.joml.Vector2f; +import org.joml.Vector3f; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.font.TextRenderable; +import net.minecraft.client.gui.navigation.ScreenRectangle; +import net.minecraft.client.gui.render.TextureSetup; +import net.minecraft.client.renderer.state.gui.GuiElementRenderState; +import net.minecraft.client.renderer.rendertype.RenderType; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.Identifier; + +public final class ClientDanmakuRenderer { + private static final float BASE_ROLLING_SURFACE_GAP = 0.003f; + private static final float ROLLING_LAYER_DEPTH = 0.002f; + private static final float BASE_FIXED_SURFACE_GAP = 0.006f; + private static final float FIXED_LAYER_DEPTH = 0.001f; + private static final float BASE_SUBTITLE_SURFACE_GAP = 0.010f; + private static final float SUBTITLE_LAYER_DEPTH = 0.001f; + private static final int LIGHT = 0xF000F0; + private static final int SUBTITLE_VERTEX_COLOR = 0xFFFFFFFF; + private static final int SUBTITLE_BACKGROUND_COLOR = 0x99000000; + private static final float SUBTITLE_BACKGROUND_PADDING_X = 4.0f; + private static final float SUBTITLE_BACKGROUND_PADDING_Y = 2.0f; + private static final float SUBTITLE_BACKGROUND_SURFACE_GAP = 0.00035f; + private static final Identifier SUBTITLE_BACKGROUND_TEXTURE = Identifier.fromNamespaceAndPath("minecraft", "textures/block/white_concrete.png"); + + private ClientDanmakuRenderer() { + } + + public static void beginFrame(Collection screens) { + if (screens == null || screens.isEmpty()) return; + for (ClientVideoScreen target : screens) { + if (target == null || target.surface == ScreenSurface.SPHERE_360) continue; + ClientVideoScreen playback = target.getScreen(); + if (playback == null) continue; + if (ClientDanmakuController.isEnabledOn(target)) { + DanmakuTextLayoutCache.prepare(playback.danmaku().renderables()); + } + DanmakuTextLayoutCache.prepare(playback.subtitles().renderables()); + } + } + + public static void clearCache() { + DanmakuTextLayoutCache.clear(); + BiliBiliSourceRegistry.clear(); + } + + public static void draw(PoseStack matrices, WorldRenderBatch consumers, ClientVideoScreen target) { + if (!ClientDanmakuController.isEnabledOn(target) || target.surface == ScreenSurface.SPHERE_360) return; + ClientVideoScreen playback = target.getScreen(); + if (playback == null) return; + List items = playback.danmaku().renderables(); + if (items.isEmpty()) return; + + RenderContext context = renderContext(target, playback, playback.danmaku().canvasWidth(), playback.danmaku().canvasHeight()); + if (context == null) return; + DanmakuTextLayoutCache.prepare(items); + drawDanmakuItems(consumers, context, target, items); + } + + public static void drawSubtitles(PoseStack matrices, WorldRenderBatch consumers, ClientVideoScreen target) { + if (target == null || target.surface == ScreenSurface.SPHERE_360) return; + ClientVideoScreen playback = target.getScreen(); + if (playback == null) return; + List items = playback.subtitles().renderables(); + if (items.isEmpty()) return; + + RenderContext context = renderContext(target, playback, playback.subtitles().canvasWidth(), playback.subtitles().canvasHeight()); + if (context == null) return; + DanmakuTextLayoutCache.prepare(items); + drawSubtitleItems(consumers, context, target, items); + } + + public static void drawPreview(GuiGraphicsExtractor context, ClientVideoScreen target, int x, int y, int width, int height) { + if (target == null || width <= 0 || height <= 0) return; + if (!ClientDanmakuController.isEnabledOn(target) || target.surface == ScreenSurface.SPHERE_360) return; + ClientVideoScreen playback = target.getScreen(); + if (playback == null) return; + float canvasWidth = previewCanvasWidth(width, height); + float canvasHeight = ClientDanmakuController.VIRTUAL_HEIGHT; + List items = playback.danmaku().renderables(canvasWidth, canvasHeight); + if (items.isEmpty()) return; + float scaleX = width / canvasWidth; + float scaleY = height / canvasHeight; + int alpha = alpha(opacityVertexColor()); + + DanmakuTextLayoutCache.prepare(items); + context.enableScissor(x, y, x + width, y + height); + try { + GuiTextBatch batch = new GuiTextBatch(new ScreenRectangle(x, y, width, height)); + for (ClientDanmakuController.RenderableDanmaku item : items) { + collectPreviewItem(context, batch, item, x, y, width, height, scaleX, scaleY, alpha); + } + batch.submit(context); + } finally { + context.disableScissor(); + } + } + + public static void drawSubtitlePreview(GuiGraphicsExtractor context, ClientVideoScreen target, int x, int y, int width, int height) { + if (target == null || width <= 0 || height <= 0 || target.surface == ScreenSurface.SPHERE_360) return; + ClientVideoScreen playback = target.getScreen(); + if (playback == null) return; + float canvasWidth = previewCanvasWidth(width, height); + float canvasHeight = ClientDanmakuController.VIRTUAL_HEIGHT; + List items = playback.subtitles().renderables(canvasWidth, canvasHeight); + if (items.isEmpty()) return; + float scaleX = width / canvasWidth; + float scaleY = height / canvasHeight; + + DanmakuTextLayoutCache.prepare(items); + context.enableScissor(x, y, x + width, y + height); + try { + GuiTextBatch batch = new GuiTextBatch(new ScreenRectangle(x, y, width, height)); + for (ClientDanmakuController.RenderableDanmaku item : items) { + drawPreviewSubtitleBackground(context, item, x, y, width, height, scaleX, scaleY); + collectPreviewItem(context, batch, item, x, y, width, height, scaleX, scaleY, alpha(SUBTITLE_VERTEX_COLOR)); + } + batch.submit(context); + } finally { + context.disableScissor(); + } + } + + private static float previewCanvasWidth(int width, int height) { + float aspect = Math.max(1, width) / (float) Math.max(1, height); + return Math.max(1.0f, ClientDanmakuController.VIRTUAL_HEIGHT * aspect); + } + + private static RenderContext renderContext(ClientVideoScreen target, ClientVideoScreen playback, float canvasWidth, float canvasHeight) { + ScreenGeometry targetGeometry; + ScreenGeometry sourceGeometry; + try { + targetGeometry = target.geometry(); + sourceGeometry = playback.geometry(); + } catch (IllegalArgumentException ignored) { + return null; + } + + int videoWidth = playback.player == null ? Math.max(1, Math.round(canvasWidth)) : Math.max(1, playback.player.getWidth()); + int videoHeight = playback.player == null ? Math.max(1, Math.round(canvasHeight)) : Math.max(1, playback.player.getHeight()); + boolean rootTarget = playback == target; + float[] sourceFullBounds = sourceGeometry.contentBounds(0, 0, 1, 1, true, 1, 1, + Math.max(1, Math.round(canvasWidth)), Math.max(1, Math.round(canvasHeight))); + SourceMapping source = new SourceMapping( + sourceFullBounds, + sourceFullBounds, + playback.u1, + playback.v1, + playback.u2, + playback.v2, + playback.player != null && playback.player.flippedX(), + playback.player != null && playback.player.flippedY(), + canvasWidth, + canvasHeight + ); + float[] targetBounds = targetGeometry.contentBounds( + target.u1, + target.v1, + target.u2, + target.v2, + target.fill, + target.scaleX, + target.scaleY, + videoWidth, + videoHeight + ); + TargetProjection projection = rootTarget + ? new TargetProjection(surfaceTriangles(targetGeometry, SurfaceCoordinates.EDIT, null, false, false, target), false) + : targetProjection(targetGeometry, target); + DirectPlane directPlane = !projection.mappedUv() ? directPlane(targetGeometry) : null; + Vector3f renderOrigin = targetGeometry.relativeOrigin(ScreenRenderer.preciseCameraX, ScreenRenderer.preciseCameraY, ScreenRenderer.preciseCameraZ); + return new RenderContext(targetGeometry, projection, source, targetBounds, rootTarget, directPlane, renderOrigin); + } + + private static void collectPreviewItem(GuiGraphicsExtractor context, GuiTextBatch batch, + ClientDanmakuController.RenderableDanmaku item, + int x, int y, int width, int height, + float scaleX, float scaleY, int alpha) { + float drawX = x + item.x() * scaleX; + float drawY = y + item.y() * scaleY; + float drawW = item.width() * scaleX; + float drawH = item.height() * scaleY; + float padX = Math.max(1.0f, item.scale() * scaleX); + float padY = Math.max(1.0f, item.scale() * scaleY); + if (drawX >= x + width + padX || drawY >= y + height + padY || drawX + drawW + padX <= x || drawY + drawH + padY <= y) { + return; + } + + Matrix3x2f pose = new Matrix3x2f(context.pose()) + .translate(drawX, drawY) + .scale(item.scale() * scaleX, item.scale() * scaleY); + Matrix4f matrix = new Matrix4f().mul(pose); + DanmakuTextLayoutCache.CachedLayout layout = DanmakuTextLayoutCache.get(item.text()); + int bodyColor = colorWithAlpha(item.color(), alpha); + layout.body().visit(new GuiGlyphCollector(batch, matrix, bodyColor)); + } + + private static void drawPreviewSubtitleBackground(GuiGraphicsExtractor context, ClientDanmakuController.RenderableDanmaku item, + int x, int y, int width, int height, float scaleX, float scaleY) { + float padX = SUBTITLE_BACKGROUND_PADDING_X * item.scale() * scaleX; + float padY = SUBTITLE_BACKGROUND_PADDING_Y * item.scale() * scaleY; + int x1 = Math.max(x, Math.round(x + item.x() * scaleX - padX)); + int y1 = Math.max(y, Math.round(y + item.y() * scaleY - padY)); + int x2 = Math.min(x + width, Math.round(x + (item.x() + item.width()) * scaleX + padX)); + int y2 = Math.min(y + height, Math.round(y + (item.y() + item.height()) * scaleY + padY)); + if (x2 > x1 && y2 > y1) { + context.fill(x1, y1, x2, y2, SUBTITLE_BACKGROUND_COLOR); + } + } + + private static void drawDanmakuItems(WorldRenderBatch consumers, RenderContext context, ClientVideoScreen target, + List items) { + WorldTextBatch batch = new WorldTextBatch(); + int rollingCount = countItems(items, false); + int rollingIndex = 0; + for (ClientDanmakuController.RenderableDanmaku item : items) { + if (item.fixed()) continue; + drawDanmakuItem(batch, context, target, item, + layerDistance(BASE_ROLLING_SURFACE_GAP, ROLLING_LAYER_DEPTH, rollingIndex++, rollingCount)); + } + + int fixedCount = countItems(items, true); + int fixedIndex = 0; + for (ClientDanmakuController.RenderableDanmaku item : items) { + if (!item.fixed()) continue; + drawDanmakuItem(batch, context, target, item, + layerDistance(BASE_FIXED_SURFACE_GAP, FIXED_LAYER_DEPTH, fixedIndex++, fixedCount)); + } + batch.submit(consumers); + } + + private static void drawSubtitleItems(WorldRenderBatch consumers, RenderContext context, ClientVideoScreen target, + List items) { + drawSubtitleBackgrounds(consumers, context, target, items); + WorldTextBatch batch = new WorldTextBatch(); + int count = items.size(); + for (int i = 0; i < count; i++) { + drawDanmakuItem(batch, context, target, items.get(i), + layerDistance(BASE_SUBTITLE_SURFACE_GAP, SUBTITLE_LAYER_DEPTH, i, count), SUBTITLE_VERTEX_COLOR); + } + batch.submit(consumers); + } + + private static void drawSubtitleBackgrounds(WorldRenderBatch consumers, RenderContext context, ClientVideoScreen target, + List items) { + VertexConsumer consumer = consumers.getBuffer(ScreenRenderer.getTranslucentLayer(SUBTITLE_BACKGROUND_TEXTURE)); + int count = items.size(); + for (int i = 0; i < count; i++) { + float textDistance = layerDistance(BASE_SUBTITLE_SURFACE_GAP, SUBTITLE_LAYER_DEPTH, i, count); + float backgroundDistance = Math.max(0.0f, textDistance - SUBTITLE_BACKGROUND_SURFACE_GAP); + Vector3f normalOffset = cameraFacingOffset(context.geometry(), backgroundDistance); + drawSubtitleBackground(consumer, context, target, items.get(i), normalOffset); + } + } + + private static void drawSubtitleBackground(VertexConsumer consumer, RenderContext context, ClientVideoScreen target, + ClientDanmakuController.RenderableDanmaku item, Vector3f normalOffset) { + float padX = SUBTITLE_BACKGROUND_PADDING_X * item.scale(); + float padY = SUBTITLE_BACKGROUND_PADDING_Y * item.scale(); + float x1 = item.x() - padX; + float y1 = item.y() - padY; + float x2 = item.x() + item.width() + padX; + float y2 = item.y() + item.height() + padY; + ClipVertex[] mapped = { + mapGlyphVertex(context, target, new GlyphVertex(x1, y1, 0.0f, 0.0f, LIGHT)), + mapGlyphVertex(context, target, new GlyphVertex(x2, y1, 1.0f, 0.0f, LIGHT)), + mapGlyphVertex(context, target, new GlyphVertex(x2, y2, 1.0f, 1.0f, LIGHT)), + mapGlyphVertex(context, target, new GlyphVertex(x1, y2, 0.0f, 1.0f, LIGHT)) + }; + drawMappedQuad(consumer, context, normalOffset, mapped, SUBTITLE_BACKGROUND_COLOR); + } + + private static int countItems(List items, boolean fixed) { + int count = 0; + for (ClientDanmakuController.RenderableDanmaku item : items) { + if (item.fixed() == fixed) count++; + } + return count; + } + + private static float layerDistance(float base, float depth, int index, int count) { + if (count <= 0) return base; + return base + depth * (index + 1.0f) / (count + 1.0f); + } + + private static void drawDanmakuItem(WorldTextBatch batch, RenderContext context, ClientVideoScreen target, + ClientDanmakuController.RenderableDanmaku item, float normalDistance) { + drawDanmakuItem(batch, context, target, item, normalDistance, opacityVertexColor()); + } + + private static void drawDanmakuItem(WorldTextBatch batch, RenderContext context, ClientVideoScreen target, + ClientDanmakuController.RenderableDanmaku item, float normalDistance, int vertexColor) { + DanmakuTextLayoutCache.CachedLayout layout = DanmakuTextLayoutCache.get(item.text()); + Vector3f normalOffset = cameraFacingOffset(context.geometry(), normalDistance); + Matrix4f matrix = new Matrix4f().translation(item.x(), item.y(), 0.0f).scale(item.scale(), item.scale(), 1.0f); + int alpha = alpha(vertexColor); + int bodyColor = colorWithAlpha(item.color(), alpha); + layout.body().visit(new MappedGlyphDrawer(batch, context, target, normalOffset, matrix, + Font.DisplayMode.POLYGON_OFFSET, bodyColor)); + } + + private static int opacityVertexColor() { + int opacity = VideoPlayerClient.config == null ? 80 : Math.clamp(VideoPlayerClient.config.danmakuOpacity, 20, 100); + int alpha = Math.clamp(Math.round(opacity * 255.0f / 100.0f), 0, 255); + return (alpha << 24) | (alpha << 16) | (alpha << 8) | alpha; + } + + private static int alpha(int color) { + return Math.clamp(color >>> 24, 0, 255); + } + + private static int colorWithAlpha(int color, int alpha) { + return (Math.clamp(alpha, 0, 255) << 24) | (color & 0x00FFFFFF); + } + + private static Vector3f cameraFacingOffset(ScreenGeometry geometry, float distance) { + Vector3f normal = geometry.normal(); + Vector3f origin = geometry.origin(); + double toCameraX = ScreenRenderer.preciseCameraX - origin.x; + double toCameraY = ScreenRenderer.preciseCameraY - origin.y; + double toCameraZ = ScreenRenderer.preciseCameraZ - origin.z; + double facing = normal.x * toCameraX + normal.y * toCameraY + normal.z * toCameraZ; + if (facing < 0.0) { + normal.negate(); + } + return normal.mul(Math.max(0.0f, distance)); + } + + private static TargetProjection targetProjection(ScreenGeometry geometry, ClientVideoScreen target) { + List mappedUvs = mappedUvs(target, geometry.localVertices().size()); + if (mappedUvs != null) { + return new TargetProjection(surfaceTriangles(geometry, SurfaceCoordinates.MAPPED_UV, mappedUvs, + target.player != null && target.player.flippedX(), + target.player != null && target.player.flippedY(), + target), true); + } + return new TargetProjection(surfaceTriangles(geometry, SurfaceCoordinates.EDIT, null, false, false, target), false); + } + + private static List surfaceTriangles(ScreenGeometry geometry, SurfaceCoordinates coordinates, + List mappedUvs, boolean flippedX, boolean flippedY, + ClientVideoScreen target) { + List vertices = geometry.localVertices(); + int[] indices = geometry.triangles(); + ArrayList result = new ArrayList<>(indices.length / 3); + for (int i = 0; i < indices.length; i += 3) { + int i1 = indices[i]; + int i2 = indices[i + 1]; + int i3 = indices[i + 2]; + Vector2f p1 = coordinate(geometry, coordinates, mappedUvs, i1, flippedX, flippedY, target); + Vector2f p2 = coordinate(geometry, coordinates, mappedUvs, i2, flippedX, flippedY, target); + Vector2f p3 = coordinate(geometry, coordinates, mappedUvs, i3, flippedX, flippedY, target); + SurfaceTriangle triangle = new SurfaceTriangle(p1, p2, p3, vertices.get(i1), vertices.get(i2), vertices.get(i3)); + if (triangle.valid()) result.add(triangle); + } + return result; + } + + private static Vector2f coordinate(ScreenGeometry geometry, SurfaceCoordinates coordinates, List mappedUvs, + int index, boolean flippedX, boolean flippedY, ClientVideoScreen target) { + if (coordinates == SurfaceCoordinates.EDIT) return geometry.editPoint(index); + Vector2f mapped = new Vector2f(mappedUvs.get(index)); + if (flippedX) mapped.x = target.u1 + target.u2 - mapped.x; + if (flippedY) mapped.y = target.v1 + target.v2 - mapped.y; + return mapped; + } + + private static List mappedUvs(ClientVideoScreen target, int vertexCount) { + if (!target.fill || target.metadata == null) return null; + float[] values = target.metadata.getFloatArray(ScreenMetadata.KEY_MAPPING_UVS); + if (values == null || values.length != vertexCount * 2) return null; + ArrayList result = new ArrayList<>(vertexCount); + for (int i = 0; i < vertexCount; i++) { + result.add(new Vector2f(values[i * 2], values[i * 2 + 1])); + } + return result; + } + + private static DirectPlane directPlane(ScreenGeometry geometry) { + List vertices = geometry.localVertices(); + if (vertices.size() != 4) return null; + + float minX = Float.POSITIVE_INFINITY; + float maxX = Float.NEGATIVE_INFINITY; + float minY = Float.POSITIVE_INFINITY; + float maxY = Float.NEGATIVE_INFINITY; + for (int i = 0; i < vertices.size(); i++) { + Vector2f point = geometry.editPoint(i); + minX = Math.min(minX, point.x); + maxX = Math.max(maxX, point.x); + minY = Math.min(minY, point.y); + maxY = Math.max(maxY, point.y); + } + float width = maxX - minX; + float height = maxY - minY; + if (width <= ScreenGeometry.EPSILON || height <= ScreenGeometry.EPSILON) return null; + + int minMin = editCorner(geometry, minX, minY); + int maxMin = editCorner(geometry, maxX, minY); + int maxMax = editCorner(geometry, maxX, maxY); + int minMax = editCorner(geometry, minX, maxY); + if (minMin < 0 || maxMin < 0 || maxMax < 0 || minMax < 0) return null; + + Vector3f origin = new Vector3f(vertices.get(minMin)); + Vector3f xAxis = new Vector3f(vertices.get(maxMin)).sub(origin).div(width); + Vector3f yAxis = new Vector3f(vertices.get(minMax)).sub(origin).div(height); + if (new Vector3f(xAxis).cross(yAxis).lengthSquared() <= 0.000001f) return null; + + Vector3f expectedMax = new Vector3f(origin) + .add(new Vector3f(xAxis).mul(width)) + .add(new Vector3f(yAxis).mul(height)); + if (expectedMax.distanceSquared(vertices.get(maxMax)) > 0.0001f) return null; + + for (int i = 0; i < vertices.size(); i++) { + Vector2f projected = geometry.projectedPoint(i); + if (geometry.unprojectLocal(projected.x, projected.y).distanceSquared(vertices.get(i)) > 0.0001f) { + return null; + } + } + return new DirectPlane(origin, xAxis, yAxis, minX, minY); + } + + private static int editCorner(ScreenGeometry geometry, float x, float y) { + List vertices = geometry.localVertices(); + for (int i = 0; i < vertices.size(); i++) { + Vector2f point = geometry.editPoint(i); + if (Math.abs(point.x - x) <= 0.0001f && Math.abs(point.y - y) <= 0.0001f) { + return i; + } + } + return -1; + } + + private static ClipVertex mapGlyphVertex(RenderContext context, ClientVideoScreen target, GlyphVertex vertex) { + if (context.rootTarget()) { + return context.source().canvasVertex(vertex); + } + if (context.projection().mappedUv()) { + return context.source().videoUvVertex(vertex); + } + ClipVertex video = context.source().videoUvVertex(vertex); + boolean flippedX = target.player != null && target.player.flippedX(); + boolean flippedY = target.player != null && target.player.flippedY(); + float targetU = flippedX ? target.u1 + target.u2 - video.x : video.x; + float targetV = flippedY ? target.v1 + target.v2 - video.y : video.y; + return new ClipVertex( + lerp(context.targetBounds()[0], context.targetBounds()[1], inverseLerp(target.u1, target.u2, targetU)), + lerp(context.targetBounds()[2], context.targetBounds()[3], inverseLerp(target.v1, target.v2, targetV)), + video.u, + video.v + ); + } + + private static List clipToTriangle(List subject, SurfaceTriangle triangle) { + return clipToPolygon(subject, List.of(triangle.c1, triangle.c2, triangle.c3)); + } + + private static List clipToRect(List subject, float[] bounds) { + return clipToPolygon(subject, List.of( + new Vector2f(bounds[0], bounds[2]), + new Vector2f(bounds[1], bounds[2]), + new Vector2f(bounds[1], bounds[3]), + new Vector2f(bounds[0], bounds[3]) + )); + } + + private static List clipToPolygon(List subject, List clipPolygon) { + ArrayList output = new ArrayList<>(subject); + boolean ccw = signedArea(clipPolygon) >= 0; + for (int i = 0; i < clipPolygon.size(); i++) { + Vector2f a = clipPolygon.get(i); + Vector2f b = clipPolygon.get((i + 1) % clipPolygon.size()); + output = clipEdge(output, a, b, ccw); + if (output.isEmpty()) return output; + } + return output; + } + + private static ArrayList clipEdge(List input, Vector2f a, Vector2f b, boolean ccw) { + ArrayList output = new ArrayList<>(); + if (input.isEmpty()) return output; + ClipVertex previous = input.getLast(); + boolean previousInside = inside(previous, a, b, ccw); + for (ClipVertex current : input) { + boolean currentInside = inside(current, a, b, ccw); + if (currentInside != previousInside) { + output.add(intersection(previous, current, a, b)); + } + if (currentInside) { + output.add(current); + } + previous = current; + previousInside = currentInside; + } + return output; + } + + private static boolean inside(ClipVertex point, Vector2f a, Vector2f b, boolean ccw) { + float cross = cross(a.x, a.y, b.x, b.y, point.x, point.y); + return ccw ? cross >= -0.0001f : cross <= 0.0001f; + } + + private static ClipVertex intersection(ClipVertex from, ClipVertex to, Vector2f a, Vector2f b) { + float fromSide = cross(a.x, a.y, b.x, b.y, from.x, from.y); + float toSide = cross(a.x, a.y, b.x, b.y, to.x, to.y); + float denominator = fromSide - toSide; + float t = Math.abs(denominator) < 0.00001f ? 0.0f : fromSide / denominator; + t = Math.clamp(t, 0.0f, 1.0f); + return new ClipVertex( + lerp(from.x, to.x, t), + lerp(from.y, to.y, t), + lerp(from.u, to.u, t), + lerp(from.v, to.v, t) + ); + } + + private static void drawMappedQuad(VertexConsumer consumer, RenderContext context, Vector3f normalOffset, + ClipVertex[] mapped, int vertexColor) { + if (context.directPlane() != null) { + float[] bounds = context.rootTarget() ? context.source().fullBounds() : context.targetBounds(); + RectRelation relation = relateToBounds(mapped, bounds); + if (relation == RectRelation.OUTSIDE) return; + if (relation == RectRelation.INSIDE) { + drawBackgroundDirectQuad(context.directPlane(), context.renderOrigin(), normalOffset, consumer, mapped, vertexColor); + return; + } + } + + ArrayList subject = new ArrayList<>(mapped.length); + for (ClipVertex vertex : mapped) { + subject.add(vertex); + } + if (!context.rootTarget() && !context.projection().mappedUv()) { + subject = new ArrayList<>(clipToRect(subject, context.targetBounds())); + } + if (subject.size() < 3) return; + for (SurfaceTriangle triangle : context.projection().triangles()) { + List clipped = clipToTriangle(subject, triangle); + if (clipped.size() < 3) continue; + ClipVertex first = clipped.getFirst(); + for (int i = 1; i < clipped.size() - 1; i++) { + drawBackgroundTriangle(triangle, context.renderOrigin(), normalOffset, consumer, first, clipped.get(i), clipped.get(i + 1), vertexColor); + } + } + } + + private static void drawBackgroundDirectQuad(DirectPlane plane, Vector3f renderOrigin, Vector3f normalOffset, VertexConsumer consumer, + ClipVertex[] mapped, int vertexColor) { + drawBackgroundPlaneVertex(plane, renderOrigin, normalOffset, consumer, mapped[0], vertexColor); + drawBackgroundPlaneVertex(plane, renderOrigin, normalOffset, consumer, mapped[1], vertexColor); + drawBackgroundPlaneVertex(plane, renderOrigin, normalOffset, consumer, mapped[2], vertexColor); + drawBackgroundPlaneVertex(plane, renderOrigin, normalOffset, consumer, mapped[3], vertexColor); + } + + private static void drawBackgroundTriangle(SurfaceTriangle triangle, Vector3f renderOrigin, Vector3f normalOffset, VertexConsumer consumer, + ClipVertex p1, ClipVertex p2, ClipVertex p3, int vertexColor) { + drawBackgroundVertex(triangle, renderOrigin, normalOffset, consumer, p1, vertexColor); + drawBackgroundVertex(triangle, renderOrigin, normalOffset, consumer, p2, vertexColor); + drawBackgroundVertex(triangle, renderOrigin, normalOffset, consumer, p3, vertexColor); + drawBackgroundVertex(triangle, renderOrigin, normalOffset, consumer, p3, vertexColor); + drawBackgroundVertex(triangle, renderOrigin, normalOffset, consumer, p1, vertexColor); + drawBackgroundVertex(triangle, renderOrigin, normalOffset, consumer, p3, vertexColor); + drawBackgroundVertex(triangle, renderOrigin, normalOffset, consumer, p2, vertexColor); + drawBackgroundVertex(triangle, renderOrigin, normalOffset, consumer, p2, vertexColor); + } + + private static void drawBackgroundVertex(SurfaceTriangle triangle, Vector3f renderOrigin, Vector3f normalOffset, VertexConsumer consumer, + ClipVertex point, int vertexColor) { + Vector3f vertex = triangle.interpolate(point.x, point.y) + .add(normalOffset) + .add(renderOrigin); + consumer.addVertex(vertex.x, vertex.y, vertex.z) + .setColor(vertexColor) + .setUv(point.u, point.v) + .setOverlay(OverlayTexture.NO_OVERLAY) + .setLight(LIGHT) + .setNormal(0.0f, 1.0f, 0.0f); + } + + private static void drawBackgroundPlaneVertex(DirectPlane plane, Vector3f renderOrigin, Vector3f normalOffset, VertexConsumer consumer, + ClipVertex point, int vertexColor) { + float x = plane.origin.x + plane.xAxis.x * (point.x - plane.minX) + plane.yAxis.x * (point.y - plane.minY) + + normalOffset.x + renderOrigin.x; + float y = plane.origin.y + plane.xAxis.y * (point.x - plane.minX) + plane.yAxis.y * (point.y - plane.minY) + + normalOffset.y + renderOrigin.y; + float z = plane.origin.z + plane.xAxis.z * (point.x - plane.minX) + plane.yAxis.z * (point.y - plane.minY) + + normalOffset.z + renderOrigin.z; + consumer.addVertex(x, y, z) + .setColor(vertexColor) + .setUv(point.u, point.v) + .setOverlay(OverlayTexture.NO_OVERLAY) + .setLight(LIGHT) + .setNormal(0.0f, 1.0f, 0.0f); + } + + private static void drawTriangle(SurfaceTriangle triangle, Vector3f renderOrigin, Vector3f normalOffset, VertexConsumer consumer, + ClipVertex p1, ClipVertex p2, ClipVertex p3, int vertexColor, int light) { + drawVertex(triangle, renderOrigin, normalOffset, consumer, p1, vertexColor, light); + drawVertex(triangle, renderOrigin, normalOffset, consumer, p2, vertexColor, light); + drawVertex(triangle, renderOrigin, normalOffset, consumer, p3, vertexColor, light); + drawVertex(triangle, renderOrigin, normalOffset, consumer, p3, vertexColor, light); + drawVertex(triangle, renderOrigin, normalOffset, consumer, p1, vertexColor, light); + drawVertex(triangle, renderOrigin, normalOffset, consumer, p3, vertexColor, light); + drawVertex(triangle, renderOrigin, normalOffset, consumer, p2, vertexColor, light); + drawVertex(triangle, renderOrigin, normalOffset, consumer, p2, vertexColor, light); + } + + private static void drawVertex(SurfaceTriangle triangle, Vector3f renderOrigin, Vector3f normalOffset, VertexConsumer consumer, + ClipVertex point, int vertexColor, int light) { + Vector3f vertex = triangle.interpolate(point.x, point.y) + .add(normalOffset) + .add(renderOrigin); + consumer.addVertex(vertex.x, vertex.y, vertex.z) + .setColor(vertexColor) + .setUv(point.u, point.v) + .setLight(light); + } + + private static void drawPlaneVertex(DirectPlane plane, Vector3f renderOrigin, Vector3f normalOffset, VertexConsumer consumer, + ClipVertex point, int vertexColor, int light) { + float x = plane.origin.x + plane.xAxis.x * (point.x - plane.minX) + plane.yAxis.x * (point.y - plane.minY) + + normalOffset.x + renderOrigin.x; + float y = plane.origin.y + plane.xAxis.y * (point.x - plane.minX) + plane.yAxis.y * (point.y - plane.minY) + + normalOffset.y + renderOrigin.y; + float z = plane.origin.z + plane.xAxis.z * (point.x - plane.minX) + plane.yAxis.z * (point.y - plane.minY) + + normalOffset.z + renderOrigin.z; + consumer.addVertex(x, y, z) + .setColor(vertexColor) + .setUv(point.u, point.v) + .setLight(light); + } + + private static float signedArea(List polygon) { + float area = 0; + for (int i = 0; i < polygon.size(); i++) { + Vector2f a = polygon.get(i); + Vector2f b = polygon.get((i + 1) % polygon.size()); + area += a.x * b.y - b.x * a.y; + } + return area * 0.5f; + } + + private static float cross(float ax, float ay, float bx, float by, float px, float py) { + return (bx - ax) * (py - ay) - (by - ay) * (px - ax); + } + + private static float lerp(float start, float end, float delta) { + return start + (end - start) * delta; + } + + private static float inverseLerp(float start, float end, float value) { + float delta = end - start; + if (Math.abs(delta) < 0.00001f) return 0.0f; + return (value - start) / delta; + } + + private record RenderContext(ScreenGeometry geometry, TargetProjection projection, SourceMapping source, + float[] targetBounds, boolean rootTarget, DirectPlane directPlane, Vector3f renderOrigin) { + private RenderContext { + renderOrigin = new Vector3f(renderOrigin); + } + } + + private record DirectPlane(Vector3f origin, Vector3f xAxis, Vector3f yAxis, float minX, float minY) { + private DirectPlane { + origin = new Vector3f(origin); + xAxis = new Vector3f(xAxis); + yAxis = new Vector3f(yAxis); + } + } + + private record SourceMapping(float[] fullBounds, float[] contentBounds, float u1, float v1, float u2, float v2, + boolean flippedX, boolean flippedY, + float canvasWidth, float canvasHeight) { + private ClipVertex canvasVertex(GlyphVertex vertex) { + return new ClipVertex( + lerp(fullBounds[0], fullBounds[1], vertex.x / canvasWidth), + lerp(fullBounds[2], fullBounds[3], vertex.y / canvasHeight), + vertex.u, + vertex.v + ); + } + + private ClipVertex videoUvVertex(GlyphVertex vertex) { + float sourceU = lerp(fullBounds[0], fullBounds[1], vertex.x / canvasWidth); + float sourceV = lerp(fullBounds[2], fullBounds[3], vertex.y / canvasHeight); + float videoU = lerp(u1, u2, inverseLerp(contentBounds[0], contentBounds[1], sourceU)); + float videoV = lerp(v1, v2, inverseLerp(contentBounds[2], contentBounds[3], sourceV)); + if (flippedX) videoU = u1 + u2 - videoU; + if (flippedY) videoV = v1 + v2 - videoV; + return new ClipVertex(videoU, videoV, vertex.u, vertex.v); + } + } + + private record TargetProjection(List triangles, boolean mappedUv) { + } + + private record SurfaceTriangle(Vector2f c1, Vector2f c2, Vector2f c3, Vector3f v1, Vector3f v2, Vector3f v3) { + private boolean valid() { + return Math.abs(cross(c1.x, c1.y, c2.x, c2.y, c3.x, c3.y)) > 0.00001f; + } + + private Vector3f interpolate(float x, float y) { + float denominator = (c2.y - c3.y) * (c1.x - c3.x) + (c3.x - c2.x) * (c1.y - c3.y); + if (Math.abs(denominator) < 0.00001f) return new Vector3f(v1); + float w1 = ((c2.y - c3.y) * (x - c3.x) + (c3.x - c2.x) * (y - c3.y)) / denominator; + float w2 = ((c3.y - c1.y) * (x - c3.x) + (c1.x - c3.x) * (y - c3.y)) / denominator; + float w3 = 1.0f - w1 - w2; + return new Vector3f(v1).mul(w1) + .add(new Vector3f(v2).mul(w2)) + .add(new Vector3f(v3).mul(w3)); + } + } + + private enum SurfaceCoordinates { + EDIT, + MAPPED_UV + } + + private static final class GuiTextBatch { + private final ScreenRectangle scissorArea; + private final Map> verticesByBatch = new HashMap<>(); + + private GuiTextBatch(ScreenRectangle scissorArea) { + this.scissorArea = scissorArea; + } + + private void add(TextRenderable drawable, Matrix4f matrix, int color) { + GuiTextBatchKey key = new GuiTextBatchKey(drawable.guiPipeline(), drawable.textureView()); + ArrayList vertices = verticesByBatch.computeIfAbsent(key, ignored -> new ArrayList<>()); + drawable.render(matrix, new GuiGlyphVertexCollector(vertices, color), LIGHT, true); + } + + private void submit(GuiGraphicsExtractor context) { + if (verticesByBatch.isEmpty() || scissorArea == null) return; + for (Map.Entry> entry : verticesByBatch.entrySet()) { + if (entry.getValue().isEmpty()) continue; + ((DrawContextAccessor) context).videoplayer$getState().addGuiElement(new GuiTextBatchRenderState( + entry.getKey().pipeline(), + entry.getKey().textureView(), + List.copyOf(entry.getValue()), + scissorArea + )); + } + } + } + + private static final class GuiGlyphCollector implements Font.GlyphVisitor { + private final GuiTextBatch batch; + private final Matrix4f matrix; + private final int color; + + private GuiGlyphCollector(GuiTextBatch batch, Matrix4f matrix, int color) { + this.batch = batch; + this.matrix = matrix; + this.color = color; + } + + @Override + public void acceptGlyph(TextRenderable.Styled glyph) { + batch.add(glyph, matrix, color); + } + + @Override + public void acceptEffect(TextRenderable rectangle) { + batch.add(rectangle, matrix, color); + } + } + + private static final class GuiGlyphVertexCollector implements VertexConsumer { + private final List vertices; + private final int color; + private float x; + private float y; + private float z; + private float u; + private float v; + + private GuiGlyphVertexCollector(List vertices, int color) { + this.vertices = vertices; + this.color = color; + } + + @Override + public VertexConsumer addVertex(float x, float y, float z) { + this.x = x; + this.y = y; + this.z = z; + return this; + } + + @Override + public VertexConsumer setColor(int red, int green, int blue, int alpha) { + return this; + } + + @Override + public VertexConsumer setColor(int color) { + return this; + } + + @Override + public VertexConsumer setUv(float u, float v) { + this.u = u; + this.v = v; + return this; + } + + @Override + public VertexConsumer setUv1(int u, int v) { + return this; + } + + @Override + public VertexConsumer setUv2(int u, int v) { + vertices.add(new GuiGlyphVertex(x, y, z, this.u, this.v, color, (v << 16) | (u & 0xFFFF))); + return this; + } + + @Override + public VertexConsumer setNormal(float x, float y, float z) { + return this; + } + + @Override + public VertexConsumer setLineWidth(float width) { + return this; + } + } + + private record GuiTextBatchRenderState(RenderPipeline pipeline, GpuTextureView textureView, + List vertices, + ScreenRectangle bounds) implements GuiElementRenderState { + @Override + public void buildVertices(VertexConsumer consumer) { + for (GuiGlyphVertex vertex : vertices) { + consumer.addVertex(vertex.x(), vertex.y(), vertex.z()) + .setColor(vertex.color()) + .setUv(vertex.u(), vertex.v()) + .setLight(vertex.light()); + } + } + + @Override + public TextureSetup textureSetup() { + return TextureSetup.singleTextureWithLightmap(textureView, RenderSystem.getSamplerCache().getClampToEdge(FilterMode.NEAREST)); + } + + @Override + public ScreenRectangle scissorArea() { + return bounds; + } + } + + private record GuiTextBatchKey(RenderPipeline pipeline, GpuTextureView textureView) { + } + + private record GuiGlyphVertex(float x, float y, float z, float u, float v, int color, int light) { + } + + private static final class WorldTextBatch { + private final Map consumers = new LinkedHashMap<>(); + + private VertexConsumer consumer(RenderType layer) { + return consumers.computeIfAbsent(layer, ignored -> new WorldGlyphVertexCollector()); + } + + private void submit(WorldRenderBatch output) { + for (Map.Entry entry : consumers.entrySet()) { + List vertices = entry.getValue().vertices(); + if (vertices.isEmpty()) continue; + VertexConsumer consumer = output.getBuffer(entry.getKey()); + for (WorldGlyphVertex vertex : vertices) { + consumer.addVertex(vertex.x(), vertex.y(), vertex.z()) + .setColor(vertex.color()) + .setUv(vertex.u(), vertex.v()) + .setLight(vertex.light()); + } + } + } + } + + private static final class WorldGlyphVertexCollector implements VertexConsumer { + private final ArrayList vertices = new ArrayList<>(); + private float x; + private float y; + private float z; + private float u; + private float v; + private int color = 0xFFFFFFFF; + + private List vertices() { + return vertices; + } + + @Override + public VertexConsumer addVertex(float x, float y, float z) { + this.x = x; + this.y = y; + this.z = z; + return this; + } + + @Override + public VertexConsumer setColor(int red, int green, int blue, int alpha) { + this.color = (Math.clamp(alpha, 0, 255) << 24) + | (Math.clamp(red, 0, 255) << 16) + | (Math.clamp(green, 0, 255) << 8) + | Math.clamp(blue, 0, 255); + return this; + } + + @Override + public VertexConsumer setColor(int color) { + this.color = color; + return this; + } + + @Override + public VertexConsumer setUv(float u, float v) { + this.u = u; + this.v = v; + return this; + } + + @Override + public VertexConsumer setUv1(int u, int v) { + return this; + } + + @Override + public VertexConsumer setUv2(int u, int v) { + vertices.add(new WorldGlyphVertex(x, y, z, this.u, this.v, color, (v << 16) | (u & 0xFFFF))); + return this; + } + + @Override + public VertexConsumer setNormal(float x, float y, float z) { + return this; + } + + @Override + public VertexConsumer setLineWidth(float width) { + return this; + } + } + + private record WorldGlyphVertex(float x, float y, float z, float u, float v, int color, int light) { + } + + private record ClipVertex(float x, float y, float u, float v) { + } + + private record GlyphVertex(float x, float y, float u, float v, int light) { + } + + private static final class MappedGlyphDrawer implements Font.GlyphVisitor { + private final WorldTextBatch batch; + private final RenderContext context; + private final ClientVideoScreen target; + private final Vector3f normalOffset; + private final Matrix4f matrix; + private final Font.DisplayMode layerType; + private final int color; + private final Map layerConsumers = new HashMap<>(); + + private MappedGlyphDrawer(WorldTextBatch batch, RenderContext context, ClientVideoScreen target, + Vector3f normalOffset, Matrix4f matrix, + Font.DisplayMode layerType, int color) { + this.batch = batch; + this.context = context; + this.target = target; + this.normalOffset = normalOffset; + this.matrix = matrix; + this.layerType = layerType; + this.color = color; + } + + @Override + public void acceptGlyph(TextRenderable.Styled glyph) { + draw(glyph); + } + + @Override + public void acceptEffect(TextRenderable rectangle) { + draw(rectangle); + } + + private void draw(TextRenderable drawable) { + RenderType layer = drawable.renderType(layerType); + MappingVertexConsumer consumer = layerConsumers.computeIfAbsent(layer, key -> + new MappingVertexConsumer(batch.consumer(key), context, target, normalOffset, color)); + drawable.render(matrix, consumer, LIGHT, false); + } + } + + private static final class MappingVertexConsumer implements VertexConsumer { + private final VertexConsumer delegate; + private final RenderContext context; + private final ClientVideoScreen target; + private final Vector3f normalOffset; + private final int color; + private final GlyphVertex[] vertices = new GlyphVertex[4]; + private int vertexCount; + private float x; + private float y; + private float u; + private float v; + private int light = LIGHT; + + private MappingVertexConsumer(VertexConsumer delegate, RenderContext context, ClientVideoScreen target, + Vector3f normalOffset, int color) { + this.delegate = delegate; + this.context = context; + this.target = target; + this.normalOffset = new Vector3f(normalOffset); + this.color = color; + } + + @Override + public VertexConsumer addVertex(float x, float y, float z) { + this.x = x; + this.y = y; + return this; + } + + @Override + public VertexConsumer setColor(int red, int green, int blue, int alpha) { + return this; + } + + @Override + public VertexConsumer setColor(int color) { + return this; + } + + @Override + public VertexConsumer setUv(float u, float v) { + this.u = u; + this.v = v; + return this; + } + + @Override + public VertexConsumer setUv1(int u, int v) { + return this; + } + + @Override + public VertexConsumer setUv2(int u, int v) { + this.light = (v << 16) | (u & 0xFFFF); + vertices[vertexCount++] = new GlyphVertex(x, y, this.u, this.v, light); + if (vertexCount == vertices.length) { + flushQuad(); + vertexCount = 0; + } + return this; + } + + @Override + public VertexConsumer setNormal(float x, float y, float z) { + return this; + } + + @Override + public VertexConsumer setLineWidth(float width) { + return this; + } + + private void flushQuad() { + ClipVertex[] mapped = new ClipVertex[vertices.length]; + for (int i = 0; i < vertices.length; i++) { + mapped[i] = mapGlyphVertex(context, target, vertices[i]); + } + if (context.directPlane() != null) { + float[] bounds = context.rootTarget() ? context.source().fullBounds() : context.targetBounds(); + RectRelation relation = relateToBounds(mapped, bounds); + if (relation == RectRelation.OUTSIDE) return; + if (relation == RectRelation.INSIDE) { + drawDirectQuad(mapped, vertices[0].light()); + return; + } + } + + ArrayList subject = new ArrayList<>(mapped.length); + for (ClipVertex vertex : mapped) { + subject.add(vertex); + } + if (!context.rootTarget() && !context.projection().mappedUv()) { + subject = new ArrayList<>(clipToRect(subject, context.targetBounds())); + } + if (subject.size() < 3) return; + int glyphLight = vertices[0].light(); + for (SurfaceTriangle triangle : context.projection().triangles()) { + List clipped = clipToTriangle(subject, triangle); + if (clipped.size() < 3) continue; + ClipVertex first = clipped.getFirst(); + for (int i = 1; i < clipped.size() - 1; i++) { + drawTriangle(triangle, context.renderOrigin(), normalOffset, delegate, first, clipped.get(i), clipped.get(i + 1), color, glyphLight); + } + } + } + + private void drawDirectQuad(ClipVertex[] mapped, int glyphLight) { + drawPlaneVertex(context.directPlane(), context.renderOrigin(), normalOffset, delegate, mapped[0], color, glyphLight); + drawPlaneVertex(context.directPlane(), context.renderOrigin(), normalOffset, delegate, mapped[1], color, glyphLight); + drawPlaneVertex(context.directPlane(), context.renderOrigin(), normalOffset, delegate, mapped[2], color, glyphLight); + drawPlaneVertex(context.directPlane(), context.renderOrigin(), normalOffset, delegate, mapped[3], color, glyphLight); + } + } + + private static RectRelation relateToBounds(ClipVertex[] vertices, float[] bounds) { + boolean anyInside = false; + boolean allInside = true; + for (ClipVertex vertex : vertices) { + boolean inside = vertex.x >= bounds[0] - 0.0001f + && vertex.x <= bounds[1] + 0.0001f + && vertex.y >= bounds[2] - 0.0001f + && vertex.y <= bounds[3] + 0.0001f; + anyInside |= inside; + allInside &= inside; + } + if (allInside) return RectRelation.INSIDE; + if (anyInside) return RectRelation.INTERSECTING; + + float minX = Float.POSITIVE_INFINITY; + float maxX = Float.NEGATIVE_INFINITY; + float minY = Float.POSITIVE_INFINITY; + float maxY = Float.NEGATIVE_INFINITY; + for (ClipVertex vertex : vertices) { + minX = Math.min(minX, vertex.x); + maxX = Math.max(maxX, vertex.x); + minY = Math.min(minY, vertex.y); + maxY = Math.max(maxY, vertex.y); + } + if (maxX < bounds[0] - 0.0001f || minX > bounds[1] + 0.0001f + || maxY < bounds[2] - 0.0001f || minY > bounds[3] + 0.0001f) { + return RectRelation.OUTSIDE; + } + return RectRelation.INTERSECTING; + } + + private enum RectRelation { + INSIDE, + INTERSECTING, + OUTSIDE + } +} diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/danmaku/DanmakuTextLayoutCache.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/danmaku/DanmakuTextLayoutCache.java new file mode 100644 index 0000000..d71837d --- /dev/null +++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/danmaku/DanmakuTextLayoutCache.java @@ -0,0 +1,88 @@ +package com.github.squi2rel.vp.danmaku; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.network.chat.Component; +import net.minecraft.util.FormattedCharSequence; + +final class DanmakuTextLayoutCache { + private static final int MAX_ENTRIES = 2048; + private static final int WHITE = 0xFFFFFFFF; + private static final Map CACHE = new LinkedHashMap<>(MAX_ENTRIES, 0.75f, true); + + private DanmakuTextLayoutCache() { + } + + static float measureWidth(String text, float scale) { + Font textRenderer = Minecraft.getInstance().font; + return Math.max(1.0f, textRenderer.width(safeText(text)) * Math.max(0.01f, scale)); + } + + static float measureHeight(float scale) { + Font textRenderer = Minecraft.getInstance().font; + return Math.max(1.0f, textRenderer.lineHeight * Math.max(0.01f, scale)); + } + + static FormattedCharSequence orderedText(String text) { + return Component.literal(safeText(text)).getVisualOrderText(); + } + + static void prepare(List items) { + if (items == null || items.isEmpty()) return; + for (ClientDanmakuController.RenderableDanmaku item : items) { + if (item != null) get(item.text()); + } + } + + static CachedLayout get(String text) { + String safe = safeText(text); + CachedLayout cached = CACHE.get(safe); + if (cached != null) return cached; + + Font textRenderer = Minecraft.getInstance().font; + FormattedCharSequence ordered = orderedText(safe); + ArrayList outlines = new ArrayList<>(8); + for (int ox = -1; ox <= 1; ox++) { + for (int oy = -1; oy <= 1; oy++) { + if (ox == 0 && oy == 0) continue; + outlines.add(textRenderer.prepareText(ordered, ox, oy, WHITE, false, true, 0)); + } + } + CachedLayout created = new CachedLayout( + List.copyOf(outlines), + textRenderer.prepareText(ordered, 0, 0, WHITE, false, true, 0), + textRenderer.width(ordered), + textRenderer.lineHeight + ); + CACHE.put(safe, created); + evictOverflow(); + return created; + } + + static void clear() { + CACHE.clear(); + } + + private static void evictOverflow() { + Iterator iterator = CACHE.keySet().iterator(); + while (CACHE.size() > MAX_ENTRIES && iterator.hasNext()) { + iterator.next(); + iterator.remove(); + } + } + + private static String safeText(String text) { + return text == null ? "" : text; + } + + record CachedLayout(List outlines, + Font.PreparedText body, + int width, + int height) { + } +} diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/filtergraph/MpvFilterGraphStore.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/filtergraph/MpvFilterGraphStore.java new file mode 100644 index 0000000..cca562c --- /dev/null +++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/filtergraph/MpvFilterGraphStore.java @@ -0,0 +1,82 @@ +package com.github.squi2rel.vp.filtergraph; + +import com.github.squi2rel.mcng.core.GraphDefinition; +import com.github.squi2rel.mcng.core.GraphDocument; +import com.github.squi2rel.mcng.core.GraphJsonCodec; +import com.github.squi2rel.mcng.core.GraphLayout; +import com.github.squi2rel.mcng.core.NodeId; +import com.github.squi2rel.mcng.core.NodeInstance; +import com.github.squi2rel.mcng.core.NodePosition; +import com.github.squi2rel.vp.VideoPlayerMain; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import net.fabricmc.loader.api.FabricLoader; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +public final class MpvFilterGraphStore { + private static final int STORE_VERSION = 1; + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + private static final GraphJsonCodec CODEC = new GraphJsonCodec(); + private static final Path PATH = FabricLoader.getInstance().getConfigDir().resolve("videoplayer").resolve("mpv-filter-graph.json"); + + private MpvFilterGraphStore() { + } + + public static State load() { + if (!Files.exists(PATH)) { + return new State(true, defaultDocument()); + } + try { + JsonObject root = JsonParser.parseString(Files.readString(PATH)).getAsJsonObject(); + boolean autoApply = !root.has("autoApply") || root.get("autoApply").getAsBoolean(); + GraphDocument document = root.has("document") && root.get("document").isJsonObject() + ? CODEC.fromJson(GSON.toJson(root.getAsJsonObject("document"))) + : defaultDocument(); + return new State(autoApply, document); + } catch (RuntimeException | IOException e) { + VideoPlayerMain.LOGGER.warn("Failed to load MPV filter graph config", e); + return new State(true, defaultDocument()); + } + } + + public static void save(State state) { + try { + Files.createDirectories(PATH.getParent()); + JsonObject root = new JsonObject(); + root.addProperty("version", STORE_VERSION); + root.addProperty("autoApply", state == null || state.autoApply()); + GraphDocument document = state == null ? defaultDocument() : state.document(); + root.add("document", JsonParser.parseString(CODEC.toJson(document)).getAsJsonObject()); + Files.writeString(PATH, GSON.toJson(root)); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public static GraphDocument defaultDocument() { + NodeId input = new NodeId("mpv_input"); + NodeId output = new NodeId("mpv_output"); + GraphDefinition graph = new GraphDefinition( + List.of( + new NodeInstance(input, MpvFilterGraphNodes.INPUT_ID, MpvFilterGraphNodes.json()), + new NodeInstance(output, MpvFilterGraphNodes.OUTPUT_ID, MpvFilterGraphNodes.json()) + ), + List.of() + ); + GraphLayout layout = new GraphLayout(Map.of( + input, new NodePosition(80, 120), + output, new NodePosition(520, 120) + )); + return GraphDocument.of(graph, layout); + } + + public record State(boolean autoApply, GraphDocument document) { + } +} diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/i18n/VpInputTexts.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/i18n/VpInputTexts.java new file mode 100644 index 0000000..6e5527f --- /dev/null +++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/i18n/VpInputTexts.java @@ -0,0 +1,17 @@ +package com.github.squi2rel.vp.i18n; + +import com.mojang.blaze3d.platform.InputConstants; +import net.minecraft.network.chat.Component; + +public final class VpInputTexts { + private VpInputTexts() { + } + + public static Component key(int keyCode) { + return InputConstants.Type.KEYSYM.getOrCreate(keyCode).getDisplayName(); + } + + public static Component mouseButton(int button) { + return InputConstants.Type.MOUSE.getOrCreate(button).getDisplayName(); + } +} diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/i18n/VpTexts.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/i18n/VpTexts.java new file mode 100644 index 0000000..309c5c2 --- /dev/null +++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/i18n/VpTexts.java @@ -0,0 +1,23 @@ +package com.github.squi2rel.vp.i18n; + +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.MutableComponent; + +public final class VpTexts { + private VpTexts() { + } + + public static MutableComponent tr(String key, String fallback, Object... args) { + return text(VpTranslation.of(key, fallback, args)); + } + + public static MutableComponent text(VpTranslation translation) { + if (translation == null || translation.isEmpty()) { + return Component.empty(); + } + if (translation.isLiteral()) { + return Component.literal(translation.fallback()); + } + return Component.translatableWithFallback(translation.key(), translation.fallback(), translation.argumentArray()); + } +} diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/mixin/client/CameraMixin.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/mixin/client/CameraMixin.java new file mode 100644 index 0000000..2853011 --- /dev/null +++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/mixin/client/CameraMixin.java @@ -0,0 +1,16 @@ +package com.github.squi2rel.vp.mixin.client; + +import com.github.squi2rel.vp.CameraRenderer; +import net.minecraft.client.Camera; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(Camera.class) +public class CameraMixin { + @Inject(method = "calculateFov", at = @At("RETURN"), cancellable = true) + private void videoplayer$cameraFov(float tickProgress, CallbackInfoReturnable cir) { + if (CameraRenderer.isRendering()) cir.setReturnValue((float) CameraRenderer.fov); + } +} diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/mixin/client/ClientPlayNetworkHandlerMixin.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/mixin/client/ClientPlayNetworkHandlerMixin.java new file mode 100644 index 0000000..cfefce8 --- /dev/null +++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/mixin/client/ClientPlayNetworkHandlerMixin.java @@ -0,0 +1,16 @@ +package com.github.squi2rel.vp.mixin.client; + +import com.github.squi2rel.vp.VideoPlayerClient; +import net.minecraft.client.multiplayer.ClientPacketListener; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(ClientPacketListener.class) +public class ClientPlayNetworkHandlerMixin { + @Inject(method = "clearLevel", at = @At("HEAD")) + public void clearWorld(CallbackInfo ci) { + VideoPlayerClient.disconnectHandler.run(); + } +} diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/mixin/client/DrawContextAccessor.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/mixin/client/DrawContextAccessor.java new file mode 100644 index 0000000..fa6ef81 --- /dev/null +++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/mixin/client/DrawContextAccessor.java @@ -0,0 +1,12 @@ +package com.github.squi2rel.vp.mixin.client; + +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.renderer.state.gui.GuiRenderState; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(GuiGraphicsExtractor.class) +public interface DrawContextAccessor { + @Accessor("guiRenderState") + GuiRenderState videoplayer$getState(); +} diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/mixin/client/GameRendererAccessor.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/mixin/client/GameRendererAccessor.java new file mode 100644 index 0000000..e41f0d7 --- /dev/null +++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/mixin/client/GameRendererAccessor.java @@ -0,0 +1,12 @@ +package com.github.squi2rel.vp.mixin.client; + +import net.minecraft.client.renderer.GameRenderer; +import net.minecraft.client.renderer.fog.FogRenderer; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(GameRenderer.class) +public interface GameRendererAccessor { + @Accessor("fogRenderer") + FogRenderer videoplayer$getFogRenderer(); +} diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/mixin/client/GameRendererMixin.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/mixin/client/GameRendererMixin.java new file mode 100644 index 0000000..09fcf1f --- /dev/null +++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/mixin/client/GameRendererMixin.java @@ -0,0 +1,18 @@ +package com.github.squi2rel.vp.mixin.client; + +import com.github.squi2rel.vp.CameraRenderer; +import com.github.squi2rel.vp.VideoPlayerClient; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.renderer.GameRenderer; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(GameRenderer.class) +public class GameRendererMixin { + @Inject(method = "renderLevel", at = @At("RETURN")) + private void videoplayer$postUpdate(DeltaTracker tickCounter, CallbackInfo ci) { + if (!CameraRenderer.isRendering()) VideoPlayerClient.postUpdate(); + } +} diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/mixin/client/GameRendererTargetAccessor.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/mixin/client/GameRendererTargetAccessor.java new file mode 100644 index 0000000..7b39596 --- /dev/null +++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/mixin/client/GameRendererTargetAccessor.java @@ -0,0 +1,17 @@ +package com.github.squi2rel.vp.mixin.client; + +import com.mojang.blaze3d.pipeline.RenderTarget; +import net.minecraft.client.renderer.GameRenderer; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Mutable; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(GameRenderer.class) +public interface GameRendererTargetAccessor { + @Accessor("mainRenderTarget") + RenderTarget videoplayer$getFramebuffer(); + + @Accessor("mainRenderTarget") + @Mutable + void videoplayer$setFramebuffer(RenderTarget framebuffer); +} diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/mixin/client/GlDeviceAccessor.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/mixin/client/GlDeviceAccessor.java new file mode 100644 index 0000000..75a9be2 --- /dev/null +++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/mixin/client/GlDeviceAccessor.java @@ -0,0 +1,11 @@ +package com.github.squi2rel.vp.mixin.client; + +import com.mojang.blaze3d.opengl.FrameBufferCache; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Invoker; + +@Mixin(targets = "com.mojang.blaze3d.opengl.GlDevice") +public interface GlDeviceAccessor { + @Invoker("frameBufferCache") + FrameBufferCache videoplayer$getFrameBufferCache(); +} diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/mixin/client/GpuDeviceAccessor.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/mixin/client/GpuDeviceAccessor.java new file mode 100644 index 0000000..0fa6fa3 --- /dev/null +++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/mixin/client/GpuDeviceAccessor.java @@ -0,0 +1,12 @@ +package com.github.squi2rel.vp.mixin.client; + +import com.mojang.blaze3d.systems.GpuDevice; +import com.mojang.blaze3d.systems.GpuDeviceBackend; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(GpuDevice.class) +public interface GpuDeviceAccessor { + @Accessor("backend") + GpuDeviceBackend videoplayer$getBackend(); +} diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/mixin/client/MinecraftClientMixin.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/mixin/client/MinecraftClientMixin.java new file mode 100644 index 0000000..b007a46 --- /dev/null +++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/mixin/client/MinecraftClientMixin.java @@ -0,0 +1,17 @@ +package com.github.squi2rel.vp.mixin.client; + +import com.github.squi2rel.vp.VideoPlayerClient; +import net.minecraft.client.Minecraft; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(Minecraft.class) +public class MinecraftClientMixin { + @Inject(method = "renderFrame", at = @At("HEAD")) + public void render(boolean tick, CallbackInfo ci) { + VideoPlayerClient.updated = false; + VideoPlayerClient.update(); + } +} diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/mixin/client/WindowMixin.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/mixin/client/WindowMixin.java new file mode 100644 index 0000000..200b6dc --- /dev/null +++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/mixin/client/WindowMixin.java @@ -0,0 +1,21 @@ +package com.github.squi2rel.vp.mixin.client; + +import com.github.squi2rel.vp.CameraRenderer; +import com.mojang.blaze3d.platform.Window; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(Window.class) +public class WindowMixin { + @Inject(method = "getWidth", at = @At("HEAD"), cancellable = true) + private void videoplayer$framebufferWidth(CallbackInfoReturnable cir) { + if (CameraRenderer.isRendering()) cir.setReturnValue(CameraRenderer.width); + } + + @Inject(method = "getHeight", at = @At("HEAD"), cancellable = true) + private void videoplayer$framebufferHeight(CallbackInfoReturnable cir) { + if (CameraRenderer.isRendering()) cir.setReturnValue(CameraRenderer.height); + } +} diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/mixin/client/WorldRendererMixin.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/mixin/client/WorldRendererMixin.java new file mode 100644 index 0000000..1f5b92c --- /dev/null +++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/mixin/client/WorldRendererMixin.java @@ -0,0 +1,16 @@ +package com.github.squi2rel.vp.mixin.client; + +import com.github.squi2rel.vp.ScreenRenderer; +import net.minecraft.client.renderer.LevelRenderer; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(LevelRenderer.class) +public class WorldRendererMixin { + @Inject(method = "addCloudsPass", at = @At("HEAD"), cancellable = true) + public void noClouds(CallbackInfo ci) { + if (ScreenRenderer.skybox) ci.cancel(); + } +} diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/render/FrameRenderSnapshot.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/render/FrameRenderSnapshot.java new file mode 100644 index 0000000..695f8f6 --- /dev/null +++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/render/FrameRenderSnapshot.java @@ -0,0 +1,59 @@ +package com.github.squi2rel.vp.render; + +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import java.util.List; +import java.util.Objects; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.rendertype.RenderType; + +public final class FrameRenderSnapshot { + public static final FrameRenderSnapshot EMPTY = new FrameRenderSnapshot(List.of()); + + private final List commands; + + public FrameRenderSnapshot(List commands) { + this.commands = List.copyOf(commands); + } + + public boolean isEmpty() { + return commands.isEmpty(); + } + + public void submit(SubmitNodeCollector collector) { + for (int i = 0; i < commands.size(); i++) { + Command command = commands.get(i); + collector.order(i).submitCustomGeometry(new PoseStack(), command.renderType(), + (pose, consumer) -> emit(command.geometry(), pose, consumer)); + } + } + + private static void emit(FrameRenderGeometry geometry, PoseStack.Pose pose, VertexConsumer consumer) { + for (int vertex = 0; vertex < geometry.vertexCount(); vertex++) { + consumer.addVertex(pose, geometry.x(vertex), geometry.y(vertex), geometry.z(vertex)) + .setColor(geometry.color(vertex)); + if (geometry.has(vertex, FrameRenderGeometry.UV)) { + consumer.setUv(geometry.u(vertex), geometry.v(vertex)); + } + if (geometry.has(vertex, FrameRenderGeometry.OVERLAY)) { + consumer.setOverlay(geometry.overlay(vertex)); + } + if (geometry.has(vertex, FrameRenderGeometry.LIGHT)) { + consumer.setLight(geometry.light(vertex)); + } + if (geometry.has(vertex, FrameRenderGeometry.NORMAL)) { + consumer.setNormal(pose, geometry.normalX(vertex), geometry.normalY(vertex), geometry.normalZ(vertex)); + } + if (geometry.has(vertex, FrameRenderGeometry.LINE_WIDTH)) { + consumer.setLineWidth(geometry.lineWidth(vertex)); + } + } + } + + public record Command(RenderType renderType, FrameRenderGeometry geometry) { + public Command { + Objects.requireNonNull(renderType, "renderType"); + Objects.requireNonNull(geometry, "geometry"); + } + } +} diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/render/WorldRenderBatch.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/render/WorldRenderBatch.java new file mode 100644 index 0000000..110a46b --- /dev/null +++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/render/WorldRenderBatch.java @@ -0,0 +1,159 @@ +package com.github.squi2rel.vp.render; + +import com.mojang.blaze3d.vertex.VertexConsumer; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.minecraft.client.renderer.rendertype.RenderType; + +public final class WorldRenderBatch { + private final Map consumers = new LinkedHashMap<>(); + + public VertexConsumer getBuffer(RenderType renderType) { + return consumers.computeIfAbsent(renderType, ignored -> new CapturingVertexConsumer()); + } + + public FrameRenderSnapshot snapshot() { + ArrayList commands = new ArrayList<>(consumers.size()); + for (Map.Entry entry : consumers.entrySet()) { + FrameRenderGeometry geometry = entry.getValue().geometry(); + if (geometry.vertexCount() > 0) { + commands.add(new FrameRenderSnapshot.Command(entry.getKey(), geometry)); + } + } + return new FrameRenderSnapshot(commands); + } + + private static final class CapturingVertexConsumer implements VertexConsumer { + private final List vertices = new ArrayList<>(); + private MutableVertex current; + + @Override + public VertexConsumer addVertex(float x, float y, float z) { + finishCurrent(); + current = new MutableVertex(); + current.x = x; + current.y = y; + current.z = z; + return this; + } + + @Override + public VertexConsumer setColor(int red, int green, int blue, int alpha) { + if (current != null) { + current.color = (Math.clamp(alpha, 0, 255) << 24) + | (Math.clamp(red, 0, 255) << 16) + | (Math.clamp(green, 0, 255) << 8) + | Math.clamp(blue, 0, 255); + } + return this; + } + + @Override + public VertexConsumer setColor(int color) { + if (current != null) current.color = color; + return this; + } + + @Override + public VertexConsumer setUv(float u, float v) { + if (current != null) { + current.u = u; + current.v = v; + current.attributes |= FrameRenderGeometry.UV; + } + return this; + } + + @Override + public VertexConsumer setUv1(int u, int v) { + if (current != null) { + current.overlay = (v << 16) | (u & 0xFFFF); + current.attributes |= FrameRenderGeometry.OVERLAY; + } + return this; + } + + @Override + public VertexConsumer setUv2(int u, int v) { + if (current != null) { + current.light = (v << 16) | (u & 0xFFFF); + current.attributes |= FrameRenderGeometry.LIGHT; + } + return this; + } + + @Override + public VertexConsumer setNormal(float x, float y, float z) { + if (current != null) { + current.normalX = x; + current.normalY = y; + current.normalZ = z; + current.attributes |= FrameRenderGeometry.NORMAL; + } + return this; + } + + @Override + public VertexConsumer setLineWidth(float width) { + if (current != null) { + current.lineWidth = width; + current.attributes |= FrameRenderGeometry.LINE_WIDTH; + } + return this; + } + + private FrameRenderGeometry geometry() { + finishCurrent(); + int count = vertices.size(); + float[] positions = new float[count * 3]; + float[] uvs = new float[count * 2]; + float[] normals = new float[count * 3]; + float[] lineWidths = new float[count]; + int[] colors = new int[count]; + int[] lights = new int[count]; + int[] overlays = new int[count]; + int[] attributes = new int[count]; + for (int i = 0; i < count; i++) { + MutableVertex vertex = vertices.get(i); + positions[i * 3] = vertex.x; + positions[i * 3 + 1] = vertex.y; + positions[i * 3 + 2] = vertex.z; + uvs[i * 2] = vertex.u; + uvs[i * 2 + 1] = vertex.v; + normals[i * 3] = vertex.normalX; + normals[i * 3 + 1] = vertex.normalY; + normals[i * 3 + 2] = vertex.normalZ; + lineWidths[i] = vertex.lineWidth; + colors[i] = vertex.color; + lights[i] = vertex.light; + overlays[i] = vertex.overlay; + attributes[i] = vertex.attributes; + } + return new FrameRenderGeometry(positions, uvs, normals, lineWidths, colors, lights, overlays, attributes); + } + + private void finishCurrent() { + if (current == null) return; + vertices.add(current); + current = null; + } + } + + private static final class MutableVertex { + private float x; + private float y; + private float z; + private float u; + private float v; + private float normalX; + private float normalY = 1.0f; + private float normalZ; + private float lineWidth = 1.0f; + private int color = 0xFFFFFFFF; + private int light; + private int overlay; + private int attributes; + } +} diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/AbstractCameraPlayer.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/AbstractCameraPlayer.java new file mode 100644 index 0000000..b7becb9 --- /dev/null +++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/AbstractCameraPlayer.java @@ -0,0 +1,108 @@ +package com.github.squi2rel.vp.video; + +import com.github.squi2rel.vp.ScreenRenderer; +import com.mojang.blaze3d.GpuFormat; +import com.mojang.blaze3d.opengl.GlTexture; +import com.mojang.blaze3d.pipeline.RenderTarget; +import com.mojang.blaze3d.pipeline.TextureTarget; +import com.mojang.blaze3d.platform.Window; +import net.minecraft.client.Minecraft; + +public abstract class AbstractCameraPlayer extends AbstractScreenPlayer implements MetaListener { + protected RenderTarget framebuffer; + private RenderTarget framebuffer1; + private RenderTarget framebuffer2; + private boolean first = true; + protected float aspect = 16f / 9f; + protected int targetWidth = 16; + protected int targetHeight = 9; + protected boolean rendered; + + protected AbstractCameraPlayer(ClientVideoScreen screen) { + super(screen); + } + + @Override + public void init() { + framebuffer1 = new TextureTarget("VideoPlayer camera 1", targetWidth, targetHeight, true, GpuFormat.RGBA8_UNORM); + framebuffer2 = new TextureTarget("VideoPlayer camera 2", targetWidth, targetHeight, true, GpuFormat.RGBA8_UNORM); + framebuffer = framebuffer1; + } + + @Override + public void cleanup() { + releaseFramebuffer(framebuffer1); + releaseFramebuffer(framebuffer2); + if (framebuffer1 != null) framebuffer1.destroyBuffers(); + if (framebuffer2 != null) framebuffer2.destroyBuffers(); + framebuffer1 = null; + framebuffer2 = null; + framebuffer = null; + rendered = false; + } + + @Override + public void swapTexture() { + framebuffer = first ? framebuffer1 : framebuffer2; + first = !first; + } + + @Override + public void updateTexture() { + Window window = Minecraft.getInstance().getWindow(); + int width = Math.max(1, window.getWidth()); + int height = Math.max(1, Math.round(width / aspect)); + if (height > window.getHeight()) { + height = Math.max(1, window.getHeight()); + width = Math.max(1, Math.round(height * aspect)); + } + targetWidth = width; + targetHeight = height; + if (framebuffer != null && (framebuffer.width != width || framebuffer.height != height)) { + releaseFramebuffer(framebuffer); + framebuffer.resize(width, height); + } + } + + @Override + public void onMetaChanged() { + aspect = screen.metadata.getFloat(ScreenMetadata.KEY_CAMERA_ASPECT, 16f / 9f); + if (!Float.isFinite(aspect) || aspect <= 0) aspect = 16f / 9f; + } + + @Override + public int getTextureId() { + return framebuffer != null && framebuffer.getColorTexture() instanceof GlTexture texture ? texture.glId() : -1; + } + + @Override + public boolean hasVideoFrame() { + return rendered && getTextureId() >= 0; + } + + @Override + public int getWidth() { + return targetWidth; + } + + @Override + public int getHeight() { + return targetHeight; + } + + @Override + public boolean flippedY() { + return true; + } + + @Override + public boolean isPostUpdate() { + return true; + } + + private static void releaseFramebuffer(RenderTarget target) { + if (target != null && target.getColorTexture() instanceof GlTexture texture) { + ScreenRenderer.releaseTexture(texture.glId()); + } + } +} diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/ClientVideoScreen.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/ClientVideoScreen.java new file mode 100644 index 0000000..e4e894b --- /dev/null +++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/ClientVideoScreen.java @@ -0,0 +1,518 @@ +package com.github.squi2rel.vp.video; + +import com.github.squi2rel.vp.ClientPacketHandler; +import com.github.squi2rel.vp.ScreenRenderer; +import com.github.squi2rel.vp.VideoPlayerClient; +import com.github.squi2rel.vp.danmaku.ClientDanmakuController; +import com.github.squi2rel.vp.i18n.VpTexts; +import com.github.squi2rel.vp.danmaku.ClientDanmakuRenderer; +import com.github.squi2rel.vp.danmaku.ClientSubtitleController; +import com.github.squi2rel.vp.render.WorldRenderBatch; +import com.github.squi2rel.vp.provider.VideoInfo; +import com.mojang.blaze3d.vertex.PoseStack; +import org.joml.Vector3f; + +import java.util.*; +import java.util.concurrent.CompletableFuture; +import net.minecraft.ChatFormatting; +import net.minecraft.client.Minecraft; +import net.minecraft.network.chat.Component; + +public class ClientVideoScreen extends VideoScreen { + public IVideoPlayer player = null; + private VideoInfo toPlay = null; + private boolean toPlayIdle; + private VideoInfo idleInfo; + private VideoInfo playingInfo; + private long toSeek = -1; + private long startTime = System.currentTimeMillis(); + public boolean interactable = true; + public int volume = 100; + private boolean idlePlaying; + private int appliedDefaultVolume = 100; + private volatile boolean loaded; + private volatile int playbackToken; + private volatile long serverPlaybackGeneration; + private volatile long serverPlaybackReporterGeneration; + private volatile long serverPlaybackReporterToken; + private volatile long serverPlaybackRequestGeneration; + private volatile VideoInfo serverPlaybackRequestInfo; + private volatile long serverPlaybackResolutionGeneration; + private volatile VideoInfo serverPlaybackResolutionInfo; + private volatile boolean serverPlaybackResolutionComplete; + private volatile CompletableFuture pendingPlaybackFuture; + private final ClientDanmakuController danmaku = new ClientDanmakuController(this); + private final ClientSubtitleController subtitles = new ClientSubtitleController(this); + + private long lastAutoSync; + private boolean autoSyncInFlight; + + private double srtt = -1; + private double rttvar = -1; + private static final double ALPHA = 0.125; + private static final double BETA = 0.25; + private static final long AUTO_SYNC_INTERVAL_MS = 1_000L; + private static final long AUTO_SYNC_TOLERANCE_MS = 1_000L; + + public ClientVideoScreen(VideoArea area, String name, Vector3f v1, Vector3f v2, Vector3f v3, Vector3f v4, String source) { + super(area, name, v1, v2, v3, v4, source); + } + + public ClientVideoScreen(VideoArea area, String name, List vertices, String source) { + super(area, name, vertices, source); + } + + public void updatePlaylist(VideoInfo[] target) { + infos.clear(); + for (VideoInfo info : target) { + infos.offer(info); + } + if (!infos.isEmpty()) clearIdlePlayback(); + if (infos.isEmpty()) toPlay = null; + } + + public void metadataChanged() { + if (metadata == null) metadata = new ScreenMetadata(); + ensureValidState(); + metadata.ensureValid(); + applyCachedOrDefaultVolume(); + interactable = metadata.getBool("interactable", true); + if (player instanceof MetaListener m) m.onMetaChanged(); + } + + public void metaChanged() { + metadataChanged(); + } + + public int defaultVolume() { + return Math.clamp(metadata == null ? 100 : metadata.getInt(ScreenMetadata.KEY_DEFAULT_VOLUME, 100), 0, 100); + } + + private void applyCachedOrDefaultVolume() { + int configured = defaultVolume(); + Integer cached = ScreenVolumeCache.get(this); + if (cached != null) { + appliedDefaultVolume = configured; + volume = cached; + return; + } + if (configured == appliedDefaultVolume) { + return; + } + appliedDefaultVolume = configured; + volume = configured; + } + + public void applyUpdate(List vertices, String source, VideoScreen displayConfig) { + String normalizedSource = source == null ? "" : source; + boolean sourceChanged = !Objects.equals(this.source == null ? "" : this.source, normalizedSource); + setVertices(vertices); + this.source = normalizedSource; + if (displayConfig != null) copyDisplayConfigFrom(displayConfig); + if (!sourceChanged) { + if (player instanceof MetaListener m) m.onMetaChanged(); + return; + } + + IVideoPlayer old = player; + player = null; + playingInfo = null; + danmaku.stop(); + if (old != null) old.cleanup(); + srtt = -1; + rttvar = -1; + + if (!VideoPlayerClient.screens.contains(this)) return; + if (this.source.isEmpty()) { + if (toPlay != null) play(toPlay, toPlayIdle); + return; + } + ClientVideoScreen parent = ((ClientVideoArea) area).getScreen(this.source); + if (parent != null) { + player = new ClonePlayer(this, parent); + } + } + + public ClientVideoScreen getScreen() { + return player == null ? this : player.screen(); + } + + public void cleanup() { + loaded = false; + cancelPendingPlayback(); + serverPlaybackGeneration = 0L; + clearServerPlaybackReportState(); + IVideoPlayer old = player; + player = null; + if (old != null) old.cleanup(); + } + + public void draw(PoseStack matrices, WorldRenderBatch consumers) { + if (shouldDrawPlaceholder()) { + boolean showIdleImage = metadata == null || metadata.getBool(ScreenMetadata.KEY_SHOW_IDLE_IMAGE, true); + if (!shouldKeepFallbackFrame(hasDisplayPlaybackContent(), showIdleImage)) return; + if (surface == ScreenSurface.SPHERE_360 && spherePreset) { + VideoPlayerRenderer.drawTexture(ScreenRenderer.placeholderTextureId(), 960, 540, matrices, consumers, this); + Degree360Player.drawTexture(ScreenRenderer.placeholderTextureId(), matrices, consumers, this); + return; + } + VideoPlayerRenderer.drawTexture(ScreenRenderer.placeholderTextureId(), 960, 540, matrices, consumers, this); + return; + } + player.draw(matrices, consumers, this); + ClientDanmakuRenderer.draw(matrices, consumers, this); + ClientDanmakuRenderer.drawSubtitles(matrices, consumers, this); + } + + public int displayTextureId() { + return shouldDrawPlaceholder() ? ScreenRenderer.placeholderTextureId() : player.getTextureId(); + } + + public int displayTextureWidth() { + return shouldDrawPlaceholder() ? 960 : Math.max(1, player.getWidth()); + } + + public int displayTextureHeight() { + return shouldDrawPlaceholder() ? 540 : Math.max(1, player.getHeight()); + } + + public void swapTexture() { + if (player != null) player.swapTexture(); + } + + public void update() { + if (player != null) player.updateTexture(); + danmaku.update(); + subtitles.update(); + + VideoInfo syncInfo = currentPlaybackInfo(); + if (syncInfo != null && syncInfo.seekable() && player != null && player.canSetProgress() + && player instanceof RateAdjustablePlayer ratePlayer && !ratePlayer.isPaused()) { + if (ratePlayer.getRate() != 1f) ratePlayer.setRate(1f); + if (!autoSyncInFlight && metadata.getBool("autoSync", false) + && System.currentTimeMillis() - lastAutoSync >= AUTO_SYNC_INTERVAL_MS) { + lastAutoSync = System.currentTimeMillis(); + autoSyncInFlight = true; + ClientPacketHandler.autoSync(this, System.currentTimeMillis(), result -> autoSyncInFlight = false); + } + } else if (player instanceof RateAdjustablePlayer ratePlayer && ratePlayer.getRate() != 1f) { + ratePlayer.setRate(1f); + } + } + + public ClientVideoScreen getTrackingScreen() { + return player == null ? this : player.getTrackingScreen(); + } + + public void load() { + if (loaded) return; + loaded = true; + if (!VideoPlayerClient.screens.contains(this)) VideoPlayerClient.screens.add(this); + applyCachedOrDefaultVolume(); + if (source.isEmpty()) { + if (toPlay != null) play(toPlay, toPlayIdle); + return; + } + ClientVideoScreen parent = (ClientVideoScreen) area.screens.stream().filter(v -> Objects.equals(v.name, source)).findAny().orElseThrow(); + ((ClientVideoArea) area).afterLoad(() -> player = new ClonePlayer(this, parent)); + } + + public void play(VideoInfo info) { + play(info, false); + } + + public void play(VideoInfo info, boolean idle) { + if (!loaded) return; + if (source.isEmpty()) { + applyCachedOrDefaultVolume(); + IVideoPlayer old = player; + IVideoPlayer replacement = VideoPlayers.from(info, this, old); + if (replacement == null) return; + player = replacement; + playingInfo = info; + idlePlaying = idle; + idleInfo = idle ? info : null; + if (player != old) { + if (old != null) old.cleanup(); + player.init(); + } + if (player instanceof MetaListener m) m.onMetaChanged(); + if (toSeek >= 0) { + startTime = System.currentTimeMillis() - toSeek; + player.setTargetTime(toSeek); + toSeek = -1; + } else { + player.setTargetTime(-1); + startTime = System.currentTimeMillis(); + } + player.play(info); + } + } + + public void setToPlay(VideoInfo info) { + setToPlay(info, false); + } + + public void setToPlay(VideoInfo info, boolean idle) { + toPlay = info; + toPlayIdle = idle; + } + + public int beginPlaybackRequest() { + cancelPlaybackFuture(); + toSeek = -1; + return ++playbackToken; + } + + public int beginServerPlaybackRequest(long generation) { + if (serverPlaybackGeneration != 0L && generation <= serverPlaybackGeneration) return -1; + serverPlaybackGeneration = generation; + if (serverPlaybackReporterGeneration != generation) { + serverPlaybackReporterGeneration = 0L; + serverPlaybackReporterToken = 0L; + } + serverPlaybackRequestGeneration = 0L; + serverPlaybackRequestInfo = null; + serverPlaybackResolutionGeneration = 0L; + serverPlaybackResolutionInfo = null; + serverPlaybackResolutionComplete = false; + return beginPlaybackRequest(); + } + + public long serverPlaybackGeneration() { + return serverPlaybackGeneration; + } + + public void setServerPlaybackReporter(long generation, long token) { + if (generation < serverPlaybackGeneration || token == 0L) return; + serverPlaybackReporterGeneration = generation; + serverPlaybackReporterToken = token; + } + + public long serverPlaybackReporterToken(long generation) { + return serverPlaybackReporterGeneration == generation ? serverPlaybackReporterToken : 0L; + } + + public void setServerPlaybackRequestInfo(long generation, VideoInfo info) { + if (generation != serverPlaybackGeneration) return; + serverPlaybackRequestGeneration = generation; + serverPlaybackRequestInfo = info; + } + + public VideoInfo serverPlaybackRequestInfo(long generation) { + return serverPlaybackRequestGeneration == generation ? serverPlaybackRequestInfo : null; + } + + public void setServerPlaybackResolution(long generation, VideoInfo info) { + if (generation != serverPlaybackGeneration || serverPlaybackRequestGeneration != generation) return; + serverPlaybackResolutionGeneration = generation; + serverPlaybackResolutionInfo = info; + serverPlaybackResolutionComplete = true; + } + + public boolean hasServerPlaybackResolution(long generation) { + return serverPlaybackResolutionComplete && serverPlaybackResolutionGeneration == generation; + } + + public VideoInfo serverPlaybackResolutionInfo(long generation) { + return hasServerPlaybackResolution(generation) ? serverPlaybackResolutionInfo : null; + } + + public boolean acceptServerPlaybackGeneration(long generation) { + if (generation < serverPlaybackGeneration) return false; + serverPlaybackGeneration = generation; + return true; + } + + public void trackPlaybackFuture(int token, CompletableFuture future) { + if (future == null) return; + if (token != playbackToken) { + future.cancel(true); + return; + } + pendingPlaybackFuture = future; + future.whenComplete((result, error) -> { + if (pendingPlaybackFuture == future) pendingPlaybackFuture = null; + }); + } + + public void failPlaybackRequest(int token) { + if (token != playbackToken) return; + toSeek = -1; + toPlay = null; + toPlayIdle = false; + } + + public boolean canAcceptPlayback(int token) { + return loaded && playbackToken == token && VideoPlayerClient.screens.contains(this); + } + + public boolean isPlaybackRequestCurrent(int token) { + return playbackToken == token; + } + + public void setToSeek(long seek) { + toSeek = seek; + } + + public long getStartTime() { + return startTime; + } + + public VideoInfo currentDisplayInfo() { + VideoInfo queued = infos.peek(); + return queued == null ? idleInfo : queued; + } + + public VideoInfo currentPlaybackInfo() { + return playingInfo == null ? currentDisplayInfo() : playingInfo; + } + + public ClientDanmakuController danmaku() { + return danmaku; + } + + public ClientSubtitleController subtitles() { + return subtitles; + } + + public boolean isIdlePlaying() { + return idlePlaying; + } + + public void clearPlaybackState() { + cancelPendingPlayback(); + clearServerPlaybackReportState(); + } + + private void cancelPendingPlayback() { + cancelPlaybackFuture(); + playbackToken++; + toPlay = null; + toPlayIdle = false; + toSeek = -1; + autoSyncInFlight = false; + playingInfo = null; + danmaku.stop(); + subtitles.stop(); + clearIdlePlayback(); + } + + private void clearIdlePlayback() { + idlePlaying = false; + idleInfo = null; + } + + private void cancelPlaybackFuture() { + CompletableFuture future = pendingPlaybackFuture; + pendingPlaybackFuture = null; + if (future != null) future.cancel(true); + } + + private void clearServerPlaybackReportState() { + serverPlaybackReporterGeneration = 0L; + serverPlaybackReporterToken = 0L; + serverPlaybackRequestGeneration = 0L; + serverPlaybackRequestInfo = null; + serverPlaybackResolutionGeneration = 0L; + serverPlaybackResolutionInfo = null; + serverPlaybackResolutionComplete = false; + } + + public void setProgress(long progress) { + startTime = System.currentTimeMillis() - progress; + danmaku.seek(progress); + if (player == null) { + toSeek = progress; + return; + } + toSeek = -1; + player.setProgress(progress); + } + + public void autoSync(long roundTrip, long syncProgress) { + int clientDelay = (int) Math.min(Integer.MAX_VALUE, Math.max(0L, roundTrip)); + if (srtt < 0) { + srtt = clientDelay; + rttvar = clientDelay / 2.0; + } else { + double delta = Math.abs(clientDelay - srtt); + if (delta > 1000) return; + rttvar = (1 - BETA) * rttvar + BETA * delta; + srtt = (1 - ALPHA) * srtt + ALPHA * clientDelay; + } + + int rtt = (int) Math.round(srtt); + syncProgress += rtt / 2; + + if (player instanceof RateAdjustablePlayer ratePlayer && !ratePlayer.isPaused()) { + if (syncProgress <= 0) return; + long progress = ratePlayer.getProgress(); + if (progress <= 0 || !player.canSetProgress()) return; + + long delta = syncProgress - progress; + if (ratePlayer.getRate() != 1f) ratePlayer.setRate(1f); + boolean corrected = Math.abs(delta) > AUTO_SYNC_TOLERANCE_MS; + if (corrected) setProgress(syncProgress); + + if (metadata.getBool("debug", false)) { + Minecraft.getInstance().player.sendOverlayMessage(Component.literal( + "local: %s, server: %s, rtt: %s, delta: %s, corrected: %s, rate: %.2f".formatted( + progress, syncProgress, rtt, delta, corrected, ratePlayer.getRate() + ) + ).withStyle(ChatFormatting.GREEN)); + } + } + } + + public void unload() { + loaded = false; + cancelPendingPlayback(); + serverPlaybackGeneration = 0L; + clearServerPlaybackReportState(); + VideoPlayerClient.screens.remove(this); + IVideoPlayer old = player; + player = null; + if (old != null) old.cleanup(); + } + + public boolean isPostUpdate() { + return player != null && player.isPostUpdate(); + } + + private boolean shouldDrawPlaceholder() { + if (source != null && !source.isEmpty()) { + return player == null || player.screen() == null || player.screen().player == null || !player.hasVideoFrame() || !player.screen().hasPlaybackContent(); + } + return player == null || !player.hasVideoFrame() || !hasPlaybackContent(); + } + + private boolean hasPlaybackContent() { + return !infos.isEmpty() || idlePlaying; + } + + private boolean hasDisplayPlaybackContent() { + if (source == null || source.isEmpty()) return hasPlaybackContent(); + ClientVideoScreen sourceScreen = player == null ? null : player.screen(); + if (sourceScreen == null && area instanceof ClientVideoArea clientArea) { + sourceScreen = clientArea.getScreen(source); + } + return sourceScreen != null && sourceScreen.hasPlaybackContent(); + } + + public static ClientVideoScreen from(VideoScreen screen) { + ClientVideoScreen client = new ClientVideoScreen(screen.area, screen.name, screen.vertices, screen.source); + client.u1 = screen.u1; + client.v1 = screen.v1; + client.u2 = screen.u2; + client.v2 = screen.v2; + client.fill = screen.fill; + client.scaleX = screen.scaleX; + client.scaleY = screen.scaleY; + client.skipPercent = screen.skipPercent; + client.metadata = screen.metadata; + client.copyDisplayConfigFrom(screen); + client.metadataChanged(); + return client; + } +} diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/ClonePlayer.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/ClonePlayer.java new file mode 100644 index 0000000..2d86f80 --- /dev/null +++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/ClonePlayer.java @@ -0,0 +1,97 @@ +package com.github.squi2rel.vp.video; + +import com.mojang.blaze3d.vertex.VertexConsumer; +import org.jetbrains.annotations.Nullable; +import org.joml.Matrix4f; +import org.joml.Vector2f; +import org.joml.Vector3f; + +public record ClonePlayer(ClientVideoScreen screen, ClientVideoScreen source) implements IVideoPlayer { + @Override + public @Nullable ClientVideoScreen screen() { + return source; + } + + @Override + public @Nullable ClientVideoScreen getTrackingScreen() { + return screen; + } + + @Override + public int getTextureId() { + return source.player.getTextureId(); + } + + @Override + public boolean hasVideoFrame() { + return source.player != null && source.player.hasVideoFrame(); + } + + @Override + public void stop() { + if (source.player != null) source.player.stop(); + } + + @Override + public AudioLevelSnapshot audioLevel() { + return source.player == null ? AudioLevelSnapshot.waiting() : source.player.audioLevel(); + } + + @Override + public void setOutputVolume(int volume) { + if (source.player != null) source.player.setOutputVolume(volume); + } + + @Override + public void clearOutputVolume() { + if (source.player != null) source.player.clearOutputVolume(); + } + + @Override + public long getProgress() { + return source.player == null ? 0 : source.player.getProgress(); + } + + @Override + public long getTotalProgress() { + return source.player == null ? 0 : source.player.getTotalProgress(); + } + + @Override + public int getWidth() { + return source.player.getWidth(); + } + + @Override + public int getHeight() { + return source.player.getHeight(); + } + + @Override + public boolean flippedX() { + return source.player != null && source.player.flippedX(); + } + + @Override + public boolean flippedY() { + return source.player != null && source.player.flippedY(); + } + + @Override + public void drawQuad(Matrix4f mat, VertexConsumer consumer, Vector3f p1, Vector3f p2, Vector3f p3, Vector3f p4, float u1, float v1, float u2, float v2) { + if (source.player == null) return; + source.player.drawQuad(mat, consumer, p1, p2, p3, p4, u1, v1, u2, v2); + } + + @Override + public void drawVertex(Matrix4f mat, VertexConsumer consumer, Vector3f vertex, Vector2f uv, ClientVideoScreen target) { + if (source.player == null) return; + source.player.drawVertex(mat, consumer, vertex, uv, target); + } + + @Override + public void drawVertex(Matrix4f mat, VertexConsumer consumer, Vector3f vertex, Vector2f uv, Vector3f normal, ClientVideoScreen target) { + if (source.player == null) return; + source.player.drawVertex(mat, consumer, vertex, uv, normal, target); + } +} diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/Degree360Player.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/Degree360Player.java new file mode 100644 index 0000000..d6bd208 --- /dev/null +++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/Degree360Player.java @@ -0,0 +1,214 @@ +package com.github.squi2rel.vp.video; + +import com.github.squi2rel.vp.ScreenRenderer; +import com.github.squi2rel.vp.render.WorldRenderBatch; +import com.github.squi2rel.vp.vivecraft.Vivecraft; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import org.joml.Matrix4f; +import org.joml.Quaternionf; +import org.joml.Vector3f; + +import java.util.LinkedHashMap; +import java.util.Map; +import net.minecraft.util.Mth; + +import static com.github.squi2rel.vp.VideoPlayerClient.config; + +public final class Degree360Player { + private static final Quaternionf tmp = new Quaternionf(); + private static final int MAX_CACHED_MESHES = 24; + private static final LinkedHashMap MESHES = new LinkedHashMap<>(16, 0.75f, false) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > MAX_CACHED_MESHES; + } + }; + + private Degree360Player() { + } + + public static void drawTexture(int textureId, PoseStack matrices, WorldRenderBatch consumers, ClientVideoScreen screen) { + drawTexture(textureId, matrices, consumers, screen, screen.stereo3d); + } + + public static void drawTexture(int textureId, PoseStack matrices, WorldRenderBatch consumers, ClientVideoScreen screen, boolean is3d) { + if (textureId < 0) return; + boolean rightEye = is3d && Vivecraft.loaded && Vivecraft.isVRActive() && Vivecraft.isRightEye(); + float[] mesh = meshFor(screen, is3d, rightEye); + if (mesh == null || mesh.length == 0) return; + + matrices.pushPose(); + if (screen.sphereSkybox) { + ScreenRenderer.skybox = true; + } else { + Vector3f center = screen.sphereCenter == null ? new Vector3f() : screen.sphereCenter; + matrices.translate( + center.x - ScreenRenderer.preciseCameraX, + center.y - ScreenRenderer.preciseCameraY, + center.z - ScreenRenderer.preciseCameraZ + ); + } + applySphereRotation(matrices, screen.sphereRotX, screen.sphereRotY, screen.sphereRotZ); + Matrix4f matrix = new Matrix4f(matrices.last().pose()); + matrices.popPose(); + + int gray = (int) (config.brightness / 100.0 * 255); + int color = 0xFF000000 | (gray << 16) | (gray << 8) | gray; + VertexConsumer consumer = consumers.getBuffer(ScreenRenderer.getLayer(textureId)); + appendSphereQuads(consumer, matrix, mesh, clampSegments(screen.sphereLat), clampSegments(screen.sphereLon), + is3d, rightEye, screen.u1, screen.u2, color); + } + + public static void clearMeshCache() { + MESHES.clear(); + } + + private static float[] meshFor(ClientVideoScreen screen, boolean stereo3d, boolean rightEye) { + int latSegments = clampSegments(screen.sphereLat); + int lonSegments = clampSegments(screen.sphereLon); + MeshKey key = MeshKey.of(screen, stereo3d, rightEye, latSegments, lonSegments); + float[] cached = MESHES.get(key); + if (cached != null) return cached; + float[] mesh = key.hemisphere + ? genHemisphereVertices(key.radius(), latSegments, lonSegments, key.u1(), key.u2(), key.v1(), key.v2()) + : genVertices(key.radius(), latSegments, lonSegments, key.u1(), key.u2(), key.v1(), key.v2()); + MESHES.put(key, mesh); + return mesh; + } + + private static void appendSphereQuads(VertexConsumer consumer, Matrix4f matrix, float[] vertices, + int latSegments, int lonSegments, boolean stereo3d, boolean rightEye, + float u1, float u2, int color) { + int row = (lonSegments + 1) * 2; + for (int latIndex = 0; latIndex < latSegments; latIndex++) { + int first = latIndex * row; + for (int lonIndex = 0; lonIndex < lonSegments; lonIndex++) { + int top = first + lonIndex * 2; + appendSphereVertex(consumer, matrix, vertices, top, stereo3d, rightEye, u1, u2, color); + appendSphereVertex(consumer, matrix, vertices, top + 1, stereo3d, rightEye, u1, u2, color); + appendSphereVertex(consumer, matrix, vertices, top + 3, stereo3d, rightEye, u1, u2, color); + appendSphereVertex(consumer, matrix, vertices, top + 2, stereo3d, rightEye, u1, u2, color); + appendSphereVertex(consumer, matrix, vertices, top + 2, stereo3d, rightEye, u1, u2, color); + appendSphereVertex(consumer, matrix, vertices, top + 3, stereo3d, rightEye, u1, u2, color); + appendSphereVertex(consumer, matrix, vertices, top + 1, stereo3d, rightEye, u1, u2, color); + appendSphereVertex(consumer, matrix, vertices, top, stereo3d, rightEye, u1, u2, color); + } + } + } + + private static void appendSphereVertex(VertexConsumer consumer, Matrix4f matrix, float[] vertices, int vertex, + boolean stereo3d, boolean rightEye, float u1, float u2, int color) { + int idx = vertex * 5; + float u = vertices[idx + 3]; + if (stereo3d) { + float split = (u1 + u2) * 0.5f; + u = rightEye ? split + (u - u1) * 0.5f : u1 + (u - u1) * 0.5f; + } + Vector3f vertexPosition = new Vector3f(vertices[idx], vertices[idx + 1], vertices[idx + 2]); + Vector3f normal = new Vector3f(vertexPosition); + if (normal.lengthSquared() == 0.0f) normal.set(0.0f, 1.0f, 0.0f); + else normal.normalize(); + ScreenRenderer.drawWorldTexturedVertex(matrix, consumer, vertexPosition, u, vertices[idx + 4], color, normal); + } + + private static void applySphereRotation(PoseStack matrices, float x, float y, float z) { + if (y != 0) matrices.mulPose(tmp.rotationY((float) Math.toRadians(y))); + if (x != 0) matrices.mulPose(tmp.rotationX((float) Math.toRadians(x))); + if (z != 0) matrices.mulPose(tmp.rotationZ((float) Math.toRadians(z))); + } + + static float[] genVertices(float radius, int latSegments, int lonSegments, float us, float ue, float vs, float ve) { + latSegments = clampSegments(latSegments); + lonSegments = clampSegments(lonSegments); + return genVertices(radius, latSegments, lonSegments, us, ue, vs, ve, 0.0, Math.PI * 2.0); + } + + static float[] genHemisphereVertices(float radius, int latSegments, int lonSegments, float us, float ue, float vs, float ve) { + latSegments = clampSegments(latSegments); + lonSegments = clampSegments(lonSegments); + return genVertices(radius, latSegments, lonSegments, us, ue, vs, ve, 0.0, Math.PI); + } + + private static int clampSegments(int value) { + return VideoScreen.clampSphereSegments(value); + } + + private static float[] genVertices(float radius, int latSegments, int lonSegments, float us, float ue, float vs, float ve, + double phiStart, double phiEnd) { + int vertexCount = latSegments * (lonSegments + 1) * 2; + float[] data = new float[vertexCount * 5]; + + int idx = 0; + double phiRange = phiEnd - phiStart; + for (int lat = 0; lat < latSegments; lat++) { + double theta1 = Math.PI * lat / latSegments; + double theta2 = Math.PI * (lat + 1) / latSegments; + for (int lon = 0; lon <= lonSegments; lon++) { + double phi = phiStart + phiRange * lon / lonSegments; + float y1 = (float) (radius * Math.cos(theta1)); + float y2 = (float) (radius * Math.cos(theta2)); + float r1 = (float) (radius * Math.sin(theta1)); + float r2 = (float) (radius * Math.sin(theta2)); + float x1 = (float) (r1 * Math.cos(phi)); + float x2 = (float) (r2 * Math.cos(phi)); + float z1 = (float) (r1 * Math.sin(phi)); + float z2 = (float) (r2 * Math.sin(phi)); + float u = Mth.lerp((float) lon / lonSegments, us, ue); + float v1 = Mth.lerp((float) lat / latSegments, vs, ve); + float v2 = Mth.lerp((float) (lat + 1) / latSegments, vs, ve); + data[idx++] = x1; + data[idx++] = y1; + data[idx++] = z1; + data[idx++] = u; + data[idx++] = v1; + data[idx++] = x2; + data[idx++] = y2; + data[idx++] = z2; + data[idx++] = u; + data[idx++] = v2; + } + } + + return data; + } + + private record MeshKey(int radiusBits, int lat, int lon, int u1Bits, int u2Bits, int v1Bits, int v2Bits, + boolean hemisphere, boolean stereo3d, boolean rightEye) { + private static MeshKey of(ClientVideoScreen screen, boolean stereo3d, boolean rightEye, int lat, int lon) { + return new MeshKey( + Float.floatToIntBits(screen.sphereRadius), + lat, + lon, + Float.floatToIntBits(screen.u1), + Float.floatToIntBits(screen.u2), + Float.floatToIntBits(screen.v1), + Float.floatToIntBits(screen.v2), + stereo3d, + stereo3d, + rightEye + ); + } + + private float radius() { + return Float.intBitsToFloat(radiusBits); + } + + private float u1() { + return Float.intBitsToFloat(u1Bits); + } + + private float u2() { + return Float.intBitsToFloat(u2Bits); + } + + private float v1() { + return Float.intBitsToFloat(v1Bits); + } + + private float v2() { + return Float.intBitsToFloat(v2Bits); + } + } + +} diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/EntityCameraPlayer.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/EntityCameraPlayer.java new file mode 100644 index 0000000..51208ba --- /dev/null +++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/EntityCameraPlayer.java @@ -0,0 +1,60 @@ +package com.github.squi2rel.vp.video; + +import com.github.squi2rel.vp.CameraRenderer; +import com.github.squi2rel.vp.provider.EntityViewProvider; +import com.github.squi2rel.vp.provider.VideoInfo; +import java.util.UUID; +import net.minecraft.client.Minecraft; +import net.minecraft.world.entity.Entity; + +public class EntityCameraPlayer extends AbstractCameraPlayer { + private Entity entity; + private UUID uuid; + private int fov = 70; + + public EntityCameraPlayer(ClientVideoScreen screen) { + super(screen); + } + + @Override + public void play(VideoInfo info) { + uuid = EntityViewProvider.canonicalUuid(info.rawPath()); + entity = findEntity(uuid); + rendered = false; + } + + @Override + public void stop() { + entity = null; + uuid = null; + rendered = false; + } + + @Override + public void updateTexture() { + if (uuid == null) return; + if (entity == null || entity.isRemoved()) entity = findEntity(uuid); + if (entity == null) { + rendered = false; + return; + } + super.updateTexture(); + CameraRenderer.renderWorld(entity, framebuffer, fov); + rendered = true; + } + + @Override + public void onMetaChanged() { + super.onMetaChanged(); + fov = screen.metadata.getInt(ScreenMetadata.KEY_CAMERA_FOV, 70); + } + + private static Entity findEntity(UUID uuid) { + Minecraft client = Minecraft.getInstance(); + if (uuid == null || client.level == null) return null; + for (Entity candidate : client.level.entitiesForRendering()) { + if (uuid.equals(candidate.getUUID())) return candidate; + } + return null; + } +} diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/ExternalGlTexture.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/ExternalGlTexture.java new file mode 100644 index 0000000..022c24a --- /dev/null +++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/ExternalGlTexture.java @@ -0,0 +1,74 @@ +package com.github.squi2rel.vp.video; + +import com.github.squi2rel.vp.mixin.client.GlDeviceAccessor; +import com.github.squi2rel.vp.mixin.client.GpuDeviceAccessor; +import com.mojang.blaze3d.GpuFormat; +import com.mojang.blaze3d.opengl.GlTexture; +import com.mojang.blaze3d.opengl.GlTextureView; +import com.mojang.blaze3d.systems.RenderSystem; +import com.mojang.blaze3d.textures.AddressMode; +import com.mojang.blaze3d.textures.FilterMode; +import com.mojang.blaze3d.textures.GpuTexture; +import net.minecraft.client.renderer.texture.AbstractTexture; + +public final class ExternalGlTexture extends AbstractTexture { + public ExternalGlTexture(int glId, int width, int height) { + WrappedTexture texture = new WrappedTexture(glId, width, height); + this.texture = texture; + this.textureView = new WrappedTextureView(texture); + this.sampler = RenderSystem.getSamplerCache().getSampler( + AddressMode.CLAMP_TO_EDGE, + AddressMode.CLAMP_TO_EDGE, + FilterMode.LINEAR, + FilterMode.LINEAR, + false + ); + } + + @Override + public void close() { + if (texture instanceof WrappedTexture wrapped) { + wrapped.markClosed(); + } + texture = null; + textureView = null; + } + + private static final class WrappedTexture extends GlTexture { + private WrappedTexture(int glId, int width, int height) { + super( + GpuTexture.USAGE_TEXTURE_BINDING, + "VideoPlayer external texture " + glId, + GpuFormat.RGBA8_UNORM, + Math.max(1, width), + Math.max(1, height), + 1, + 1, + glId, + ((GlDeviceAccessor) ((GpuDeviceAccessor) (Object) RenderSystem.getDevice()).videoplayer$getBackend()) + .videoplayer$getFrameBufferCache() + ); + } + + @Override + public void close() { + markClosed(); + } + + private void markClosed() { + this.closed = true; + } + } + + private static final class WrappedTextureView extends GlTextureView { + private WrappedTextureView(WrappedTexture texture) { + super( + texture, + 0, + 1, + ((GlDeviceAccessor) ((GpuDeviceAccessor) (Object) RenderSystem.getDevice()).videoplayer$getBackend()) + .videoplayer$getFrameBufferCache() + ); + } + } +} diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/FramebufferBackedTexture.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/FramebufferBackedTexture.java new file mode 100644 index 0000000..4fa9cf4 --- /dev/null +++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/FramebufferBackedTexture.java @@ -0,0 +1,39 @@ +package com.github.squi2rel.vp.video; + +import com.mojang.blaze3d.pipeline.RenderTarget; +import com.mojang.blaze3d.systems.RenderSystem; +import com.mojang.blaze3d.textures.AddressMode; +import com.mojang.blaze3d.textures.FilterMode; +import net.minecraft.client.renderer.texture.AbstractTexture; + +public final class FramebufferBackedTexture extends AbstractTexture { + private final RenderTarget framebuffer; + + public FramebufferBackedTexture(RenderTarget framebuffer) { + this(framebuffer, FilterMode.LINEAR); + } + + public FramebufferBackedTexture(RenderTarget framebuffer, FilterMode filterMode) { + this.framebuffer = framebuffer; + this.sampler = RenderSystem.getSamplerCache().getSampler( + AddressMode.CLAMP_TO_EDGE, + AddressMode.CLAMP_TO_EDGE, + filterMode, + filterMode, + false + ); + updateAttachment(); + } + + public void updateAttachment() { + this.texture = framebuffer.getColorTexture(); + this.textureView = framebuffer.getColorTextureView(); + } + + @Override + public void close() { + framebuffer.destroyBuffers(); + texture = null; + textureView = null; + } +} diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/IVideoPlayer.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/IVideoPlayer.java new file mode 100644 index 0000000..05429a3 --- /dev/null +++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/IVideoPlayer.java @@ -0,0 +1,126 @@ +package com.github.squi2rel.vp.video; + +import com.github.squi2rel.vp.provider.VideoInfo; +import com.github.squi2rel.vp.render.WorldRenderBatch; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import org.jetbrains.annotations.Nullable; +import org.joml.Matrix4f; +import org.joml.Vector2f; +import org.joml.Vector3f; + +@SuppressWarnings("unused") +public interface IVideoPlayer { + @Nullable ClientVideoScreen screen(); + + default @Nullable ClientVideoScreen getTrackingScreen() { + return screen(); + } + + default boolean canPause() { + return false; + } + + default void init() { + } + + int getWidth(); + + int getHeight(); + + default void play(VideoInfo info) { + } + + default void cleanup() { + } + + int getTextureId(); + + default boolean hasVideoFrame() { + return getTextureId() >= 0 && getWidth() > 1 && getHeight() > 1; + } + + default void stop() { + } + + default void pause(boolean pause) { + } + + default boolean isPaused() { + return false; + } + + default void setVolume(int volume) { + } + + default void setOutputVolume(int volume) { + setVolume(volume); + } + + default void clearOutputVolume() { + } + + default AudioLevelSnapshot audioLevel() { + return AudioLevelSnapshot.unsupported(); + } + + default boolean canSetProgress() { + return false; + } + + default void setProgress(long progress) { + } + + default long getProgress() { + return 0; + } + + default long getTotalProgress() { + return 0; + } + + default void setTargetTime(long targetTime) { + } + + default void swapTexture() { + } + + default void updateTexture() { + } + + default boolean isPostUpdate() { + return false; + } + + default boolean flippedX() { + return false; + } + + default boolean flippedY() { + return false; + } + + default void draw(PoseStack matrices, WorldRenderBatch consumers, ClientVideoScreen s) { + VideoPlayerRenderer.draw(this, matrices, consumers, s); + } + + default void drawVertex(Matrix4f mat, VertexConsumer consumer, Vector3f vertex, Vector2f uv, ClientVideoScreen target) { + drawVertex(mat, consumer, vertex, uv); + } + + default void drawVertex(Matrix4f mat, VertexConsumer consumer, Vector3f vertex, Vector2f uv, Vector3f normal, ClientVideoScreen target) { + drawVertex(mat, consumer, vertex, uv, normal); + } + + default void drawVertex(Matrix4f mat, VertexConsumer consumer, Vector3f vertex, Vector2f uv) { + VideoPlayerRenderer.drawVertex(mat, consumer, vertex, uv.x, uv.y); + } + + default void drawVertex(Matrix4f mat, VertexConsumer consumer, Vector3f vertex, Vector2f uv, Vector3f normal) { + VideoPlayerRenderer.drawVertex(mat, consumer, vertex, uv.x, uv.y, normal); + } + + default void drawQuad(Matrix4f mat, VertexConsumer consumer, Vector3f p1, Vector3f p2, Vector3f p3, Vector3f p4, float u1, float v1, float u2, float v2) { + VideoPlayerRenderer.drawQuad(mat, consumer, p1, p2, p3, p4, u1, v1, u2, v2); + } +} diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/MpvVideoBackend.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/MpvVideoBackend.java new file mode 100644 index 0000000..e575810 --- /dev/null +++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/MpvVideoBackend.java @@ -0,0 +1,1401 @@ +package com.github.squi2rel.vp.video; + +import com.github.squi2rel.vp.ScreenRenderer; +import com.github.squi2rel.vp.VideoPlayerMain; +import com.github.squi2rel.vp.VideoPlayerClient; +import com.github.squi2rel.vp.filtergraph.MpvLavfiFilterCatalog; +import com.github.squi2rel.vp.provider.MediaAddressPolicy; +import com.github.squi2rel.vp.provider.VideoInfo; +import com.sun.jna.Memory; +import com.sun.jna.Native; +import com.sun.jna.Pointer; +import com.sun.jna.ptr.PointerByReference; +import org.lwjgl.BufferUtils; +import org.lwjgl.PointerBuffer; +import org.lwjgl.opengl.GL; + +import java.nio.ByteBuffer; +import java.nio.IntBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BiConsumer; +import net.minecraft.client.Minecraft; + +import static com.github.squi2rel.vp.video.MpvLibrary.*; +import static org.lwjgl.glfw.GLFW.*; +import static org.lwjgl.opengl.GL13.*; +import static org.lwjgl.opengl.GL14.*; +import static org.lwjgl.opengl.GL15.*; +import static org.lwjgl.opengl.GL11.*; +import static org.lwjgl.opengl.GL12.*; +import static org.lwjgl.opengl.GL20.*; +import static org.lwjgl.opengl.GL21.*; +import static org.lwjgl.opengl.GL30.*; +import static org.lwjgl.opengl.GL32.*; +import static org.lwjgl.system.MemoryUtil.NULL; +import static org.lwjgl.system.MemoryUtil.memUTF8; + + +public class MpvVideoBackend implements VideoBackend { + private static final int INITIAL_SIZE = 1; + private static final int PROPERTY_POLL_INTERVAL_MS = 100; + private static final int SHARED_TEXTURE_COUNT = 3; + private static final int SINGLE_CONTEXT_TEXTURE_INDEX = 0; + private static final long FRAME_SYNC_WAIT_NS = 2_000_000L; + private static final long RENDER_THREAD_JOIN_MS = 1_500L; + private static final int FRAME_SYNC_TIMEOUT_WARN_STREAK = 60; + private static final String AUDIO_METER_LABEL = "videoplayer_audio_meter"; + private static final String AUDIO_METER_FILTER = "@" + AUDIO_METER_LABEL + ":lavfi=[astats=metadata=1:reset=1]"; + private static final Set ACTIVE_BACKENDS = ConcurrentHashMap.newKeySet(); + private static volatile boolean libraryLoaded; + private static volatile boolean sharedContextUnavailable; + + private final LibMpv lib; + private volatile boolean singleContext; + private final BiConsumer sizeListener; + private final LinkedBlockingQueue tasks = new LinkedBlockingQueue<>(); + private final AtomicBoolean released = new AtomicBoolean(false); + private final AtomicBoolean acceptingFrames = new AtomicBoolean(true); + private final AtomicBoolean renderUpdate = new AtomicBoolean(false); + private final AtomicBoolean renderThreadStopped = new AtomicBoolean(true); + private final Object renderLock = new Object(); + private final Object publishLock = new Object(); + private final MpvProgressClock progressClock = new MpvProgressClock(); + + private Thread eventThread; + private Thread renderThread; + private volatile Pointer handle; + private volatile Pointer renderContext; + private volatile long sharedWindow = NULL; + private MpvOpenGLProcAddressCallback glProcCallback; + private MpvOpenGLInitParams glInitParams; + private MpvRenderUpdateCallback updateCallback; + + private volatile int pendingWidth = INITIAL_SIZE; + private volatile int pendingHeight = INITIAL_SIZE; + private volatile int width = INITIAL_SIZE; + private volatile int height = INITIAL_SIZE; + private volatile int displayTextureId = -1; + private volatile int publishedTextureId = -1; + private long pendingReadySync = NULL; + private final int[] textureIds = {-1, -1, -1}; + private final int[] fboIds = {-1, -1, -1}; + private int renderTextureIndex; + private int frameSyncTimeoutStreak; + + private volatile boolean loaded; + private volatile boolean paused = true; + private volatile boolean desiredPaused; + private volatile boolean seekable = true; + private volatile boolean renderFailed; + private volatile long targetTime = -1; + private volatile int volume = 100; + private volatile boolean currentVideoInputAvailable = true; + private volatile boolean currentAudioInputAvailable = true; + private volatile AudioLevelSnapshot audioLevel = AudioLevelSnapshot.waiting(); + private volatile String lastAudioMeterPayload; + private VideoInfo pendingInfo; + private long pendingTargetTime = -1; + private int pendingVolume = 100; + private boolean pendingPlay; + + public MpvVideoBackend(BiConsumer sizeListener) { + this.lib = MpvLibrary.get(); + this.singleContext = VideoPlayerMain.android || sharedContextUnavailable; + libraryLoaded = true; + this.sizeListener = sizeListener; + } + + public static boolean isAvailable() { + boolean available = MpvLibrary.isAvailable(); + if (available) libraryLoaded = true; + return available; + } + + public static Throwable loadError() { + return MpvLibrary.loadError(); + } + + public static boolean isLoaded() { + return libraryLoaded; + } + + public static synchronized void resetAvailability() { + if (libraryLoaded || !ACTIVE_BACKENDS.isEmpty()) return; + MpvLibrary.resetLoadState(); + MpvLavfiFilterCatalog.reset(); + } + + public static int applyLavfiComplexToAll(String graph) { + String safeGraph = graph == null ? "" : graph; + int count = 0; + for (MpvVideoBackend backend : ACTIVE_BACKENDS) { + if (backend.released.get()) continue; + backend.submit(ctx -> backend.setString(ctx, "lavfi-complex", backend.lavfiComplexForCurrentMedia(safeGraph))); + count++; + } + return count; + } + + @Override + public String name() { + return VideoBackends.MPV; + } + + @Override + public void init() { + CompletableFuture created = new CompletableFuture<>(); + eventThread = new Thread(() -> eventLoop(created), "VideoPlayer-MPV"); + eventThread.setDaemon(true); + eventThread.start(); + + handle = created.join(); + ACTIVE_BACKENDS.add(this); + if (singleContext) { + renderThreadStopped.set(true); + notifySize(width, height); + return; + } + + try { + sharedWindow = createSharedWindow(); + } catch (IllegalStateException e) { + sharedContextUnavailable = true; + singleContext = true; + renderThreadStopped.set(true); + VideoPlayerMain.LOGGER.warn("{} Using Minecraft's OpenGL context for MPV rendering.", e.getMessage()); + notifySize(width, height); + return; + } + CompletableFuture rendererReady = new CompletableFuture<>(); + renderThreadStopped.set(false); + renderThread = new Thread(() -> renderLoop(rendererReady), "VideoPlayer-MPV-GL"); + renderThread.setDaemon(true); + renderThread.start(); + rendererReady.join(); + notifySize(width, height); + } + + @Override + public void play(VideoInfo info, long targetTime, int volume) { + renderFailed = false; + loaded = false; + desiredPaused = false; + synchronized (this) { + pendingInfo = info; + pendingTargetTime = targetTime; + pendingVolume = volume; + pendingPlay = true; + } + flushPendingPlay(); + } + + private void startPlayback(Pointer ctx, VideoInfo info, long targetTime, int volume) { + if (released.get()) return; + if (info == null || (!info.path().isBlank() && !MediaAddressPolicy.isAllowed(info.path()) + || VideoParams.hasDisallowedMediaUrls(info.params()))) { + renderFailed = true; + loaded = false; + VideoPlayerMain.LOGGER.warn("Rejected unsupported media address"); + return; + } + this.targetTime = targetTime; + this.volume = volume; + loaded = false; + paused = desiredPaused; + progressClock.reset(desiredPaused); + pendingWidth = INITIAL_SIZE; + pendingHeight = INITIAL_SIZE; + MediaInputs inputs = mediaInputs(info); + currentVideoInputAvailable = inputs.video(); + currentAudioInputAvailable = inputs.audio(); + audioLevel = inputs.audio() ? AudioLevelSnapshot.waiting() : AudioLevelSnapshot.noAudio(); + lastAudioMeterPayload = null; + String path = VideoParams.normalizeStreamPath(info.path()); + String graph = ""; + String loadOptions = VideoParams.mpvLoadOptionsForPath(info.path(), info.params(), configuredProxy(), configuredYtdlPath(), graph); + setDouble(ctx, "volume", volume); + setFlag(ctx, "pause", desiredPaused); + loadFile(ctx, path, loadOptions); + if (released.get()) { + stopNativePlayback(ctx); + } + } + + private static String configuredProxy() { + return VideoPlayerClient.config == null ? "" : VideoPlayerClient.config.nativeDownloadProxy; + } + + private static String configuredYtdlPath() { + return VideoPlayerClient.config == null ? "" : VideoPlayerClient.config.mpvYtdlPath; + } + + @Override + public void updateTexture() { + if (singleContext) { + updateTextureSingleContext(); + return; + } + if (released.get() || !acceptingFrames.get()) { + discardPendingReadySync(); + return; + } + + long readySync; + int readyTextureId; + synchronized (publishLock) { + readySync = pendingReadySync; + readyTextureId = publishedTextureId; + pendingReadySync = NULL; + } + if (readySync == NULL) return; + + int waitResult = glClientWaitSync(readySync, GL_SYNC_FLUSH_COMMANDS_BIT, FRAME_SYNC_WAIT_NS); + if (waitResult == GL_ALREADY_SIGNALED || waitResult == GL_CONDITION_SATISFIED) { + glDeleteSync(readySync); + displayTextureId = readyTextureId; + frameSyncTimeoutStreak = 0; + return; + } + + glDeleteSync(readySync); + if (waitResult == GL_WAIT_FAILED) { + frameSyncTimeoutStreak = 0; + return; + } + + frameSyncTimeoutStreak++; + if (frameSyncTimeoutStreak == FRAME_SYNC_TIMEOUT_WARN_STREAK) { + VideoPlayerMain.LOGGER.warn( + "MPV frame fence wait timed out {} times; keeping last displayed frame to avoid freezes", + frameSyncTimeoutStreak + ); + } + } + + private void updateTextureSingleContext() { + if (released.get() || !acceptingFrames.get() || renderFailed || handle == null) return; + try { + ensureSingleContextRenderer(); + if (renderContext == null) return; + + boolean shouldRender = false; + if (renderUpdate.getAndSet(false)) { + long flags = lib.mpv_render_context_update(renderContext); + shouldRender = (flags & MPV_RENDER_UPDATE_FRAME) != 0; + } + + applyPendingSize(); + + if (shouldRender) { + renderFrameSingleContext(); + } + } catch (Throwable t) { + renderFailed = true; + VideoPlayerMain.LOGGER.error("MPV single-context render failed; disabling MPV rendering.", t); + } + } + + private void ensureSingleContextRenderer() { + if (renderContext != null) return; + initTexture(); + createRenderContext(); + notifySize(width, height); + flushPendingPlay(); + } + + @Override + public int getTextureId() { + return displayTextureId; + } + + @Override + public boolean hasVideoFrame() { + return loaded && displayTextureId >= 0 && width > 1 && height > 1; + } + + @Override + public int getWidth() { + return width; + } + + @Override + public int getHeight() { + return height; + } + + @Override + public void stop() { + synchronized (this) { + pendingInfo = null; + pendingPlay = false; + } + loaded = false; + paused = true; + resetCurrentMediaInputs(); + audioLevel = AudioLevelSnapshot.waiting(); + progressClock.reset(true); + submit(ctx -> command(ctx, "stop")); + } + + @Override + public boolean canPause() { + return !released.get() && loaded; + } + + @Override + public void pause(boolean pause) { + desiredPaused = pause; + paused = pause; + progressClock.setPaused(pause); + submit(ctx -> setFlag(ctx, "pause", pause)); + } + + @Override + public boolean isPaused() { + return paused; + } + + @Override + public void setVolume(int volume) { + this.volume = volume; + submit(ctx -> setDouble(ctx, "volume", volume)); + } + + @Override + public AudioLevelSnapshot audioLevel() { + return audioLevel; + } + + @Override + public boolean canSetProgress() { + return !released.get() && loaded && seekable; + } + + @Override + public void setProgress(long progress) { + progressClock.seekTo(progress); + submit(ctx -> seek(ctx, progress)); + } + + @Override + public long getProgress() { + return currentProgress(); + } + + @Override + public long getTotalProgress() { + return progressClock.durationMs(); + } + + @Override + public void setRate(float rate) { + progressClock.setRate(rate); + submit(ctx -> setDouble(ctx, "speed", rate)); + } + + @Override + public float getRate() { + return progressClock.rate(); + } + + @Override + public boolean isPostUpdate() { + return singleContext; + } + + @Override + public void cleanup() { + ACTIVE_BACKENDS.remove(this); + if (!released.compareAndSet(false, true)) return; + acceptingFrames.set(false); + releaseRegisteredTextures(); + discardPendingPlayback(); + tasks.clear(); + if (singleContext) { + renderThreadStopped.set(false); + cleanupSingleContextRenderer(); + return; + } + + signalRenderThread(); + Pointer ctx = handle; + if (ctx != null) lib.mpv_wakeup(ctx); + discardPendingReadySyncOnRenderThread(); + Minecraft client = Minecraft.getInstance(); + if (!client.isSameThread()) { + joinRenderThread(RENDER_THREAD_JOIN_MS); + } + } + + private void discardPendingPlayback() { + synchronized (this) { + pendingInfo = null; + pendingPlay = false; + } + loaded = false; + paused = true; + resetCurrentMediaInputs(); + audioLevel = AudioLevelSnapshot.waiting(); + progressClock.reset(true); + } + + private void cleanupSingleContextRenderer() { + Minecraft client = Minecraft.getInstance(); + Runnable cleanup = () -> { + try { + runSingleContextCleanup(); + } catch (Throwable t) { + VideoPlayerMain.LOGGER.warn("Failed to clean up MPV single-context renderer", t); + } finally { + renderThreadStopped.set(true); + Pointer ctx = handle; + if (ctx != null) lib.mpv_wakeup(ctx); + } + }; + if (client.isSameThread()) { + cleanup.run(); + return; + } + client.execute(cleanup); + } + + private void runSingleContextCleanup() { + acceptingFrames.set(false); + try { + freeRenderContext(); + } finally { + cleanupTexture(); + } + } + + private void discardPendingReadySyncOnRenderThread() { + Minecraft client = Minecraft.getInstance(); + Runnable discard = this::discardPendingReadySync; + if (client.isSameThread()) { + discard.run(); + return; + } + client.execute(discard); + } + + private void discardPendingReadySync() { + long sync; + synchronized (publishLock) { + sync = pendingReadySync; + pendingReadySync = NULL; + publishedTextureId = -1; + } + if (sync != NULL) { + glDeleteSync(sync); + } + frameSyncTimeoutStreak = 0; + } + + private void joinRenderThread(long timeoutMs) { + Thread thread = renderThread; + if (thread == null || !thread.isAlive()) { + renderThreadStopped.set(true); + return; + } + try { + thread.join(Math.max(1L, timeoutMs)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + if (thread.isAlive()) { + VideoPlayerMain.LOGGER.warn("MPV render thread did not stop within {}ms; continuing without blocking", timeoutMs); + return; + } + renderThreadStopped.set(true); + } + + private long currentProgress() { + return progressClock.currentProgress(); + } + + private void eventLoop(CompletableFuture created) { + Pointer ctx = null; + try { + ctx = lib.mpv_create(); + if (ctx == null) throw new IllegalStateException("mpv_create returned null"); + handle = ctx; + setOptionString(ctx, "config", "no"); + setOptionString(ctx, "terminal", "no"); + setOptionString(ctx, "vo", "libmpv"); + setOptionString(ctx, "hwdec", "auto-safe"); + setOptionString(ctx, "audio-channels", VideoPlayerClient.activeAudioChannelMode().mpvAudioChannelsOption()); + setOptionString(ctx, "af", AUDIO_METER_FILTER); + check(lib.mpv_initialize(ctx), "mpv_initialize"); + created.complete(ctx); + + long lastPoll = 0; + while (!released.get() || !renderThreadStopped.get()) { + runQueuedTasks(ctx); + Pointer eventPointer = lib.mpv_wait_event(ctx, 0.02); + if (eventPointer != null) { + handleEvent(ctx, new MpvEvent(eventPointer)); + } + long now = System.currentTimeMillis(); + if (now - lastPoll >= PROPERTY_POLL_INTERVAL_MS) { + lastPoll = now; + refreshProperties(ctx); + } + } + } catch (Throwable t) { + created.completeExceptionally(t); + if (!released.get()) { + VideoPlayerMain.LOGGER.error("MPV backend stopped unexpectedly", t); + } + } finally { + if (ctx != null) { + try { + lib.mpv_terminate_destroy(ctx); + } catch (RuntimeException e) { + VideoPlayerMain.LOGGER.warn("Failed to destroy MPV handle", e); + } + } + handle = null; + } + } + + private void runQueuedTasks(Pointer ctx) { + MpvTask task; + while ((task = tasks.poll()) != null) { + try { + task.run(ctx); + } catch (RuntimeException e) { + if (!released.get()) { + VideoPlayerMain.LOGGER.warn("Failed to run MPV command", e); + } + } + } + } + + private void handleEvent(Pointer ctx, MpvEvent event) { + switch (event.event_id) { + case MPV_EVENT_NONE -> { + } + case MPV_EVENT_FILE_LOADED -> { + if (released.get()) { + stopNativePlayback(ctx); + return; + } + loaded = true; + refreshProperties(ctx); + setFlag(ctx, "pause", desiredPaused); + paused = desiredPaused; + if (targetTime > 0) { + progressClock.seekTo(targetTime); + seek(ctx, targetTime); + } + } + case MPV_EVENT_VIDEO_RECONFIG -> refreshProperties(ctx); + case MPV_EVENT_END_FILE -> { + loaded = false; + paused = true; + resetCurrentMediaInputs(); + audioLevel = AudioLevelSnapshot.waiting(); + progressClock.reset(true); + if (event.data != null) { + MpvEventEndFile end = new MpvEventEndFile(event.data); + if (end.reason == MPV_END_FILE_REASON_EOF) { + pendingWidth = INITIAL_SIZE; + pendingHeight = INITIAL_SIZE; + } else if (end.error < 0) { + VideoPlayerMain.LOGGER.warn("MPV ended playback with error {}: {}", end.error, lib.mpv_error_string(end.error)); + } + } + } + case MPV_EVENT_SHUTDOWN -> { + released.set(true); + signalRenderThread(); + } + default -> { + } + } + } + + private void refreshProperties(Pointer ctx) { + Double dwidth = getDouble(ctx, "dwidth"); + Double dheight = getDouble(ctx, "dheight"); + if (dwidth != null && dheight != null && dwidth > 0 && dheight > 0) { + int nextWidth = Math.max(1, dwidth.intValue()); + int nextHeight = Math.max(1, dheight.intValue()); + if (!VideoFrameLimits.valid(nextWidth, nextHeight)) { + renderFailed = true; + VideoPlayerMain.LOGGER.warn("Rejected MPV video frame dimensions {}x{}", nextWidth, nextHeight); + } else if (nextWidth != pendingWidth || nextHeight != pendingHeight) { + pendingWidth = nextWidth; + pendingHeight = nextHeight; + signalRenderThread(); + } + } + + Double duration = getDouble(ctx, "duration"); + if (duration != null && duration > 0) { + progressClock.setDurationMs(Math.round(duration * 1000)); + } + + Double timePos = getDouble(ctx, "time-pos"); + if (timePos != null && timePos >= 0) { + progressClock.updateFromTimePos(timePos); + } + + Boolean pause = getFlag(ctx, "pause"); + if (pause != null) { + paused = pause; + progressClock.setPaused(pause); + } + + Boolean canSeek = getFlag(ctx, "seekable"); + if (canSeek != null) seekable = canSeek; + + Double speed = getDouble(ctx, "speed"); + if (speed != null && speed > 0) progressClock.setRate(speed.floatValue()); + + if (!loaded) { + audioLevel = AudioLevelSnapshot.waiting(); + lastAudioMeterPayload = null; + } else if (!currentAudioInputAvailable) { + audioLevel = AudioLevelSnapshot.noAudio(); + lastAudioMeterPayload = null; + } else { + String payload = getString(ctx, "af-metadata/" + AUDIO_METER_LABEL); + if (!paused && !Objects.equals(payload, lastAudioMeterPayload)) { + lastAudioMeterPayload = payload; + audioLevel = MpvAudioLevelParser.parse(payload, System.currentTimeMillis()); + } + } + } + + private void submit(MpvTask task) { + if (released.get()) return; + tasks.offer(task); + Pointer ctx = handle; + if (ctx != null) lib.mpv_wakeup(ctx); + } + + private String lavfiComplexForCurrentMedia(String graph) { + return graph == null ? "" : graph; + } + + private void resetCurrentMediaInputs() { + currentVideoInputAvailable = true; + currentAudioInputAvailable = true; + lastAudioMeterPayload = null; + } + + private static MediaInputs mediaInputs(VideoInfo info) { + if (info == null) return new MediaInputs(true, true); + boolean audioOnly = VideoParams.isAudioOnly(info.params()) + || VideoParams.looksAudioOnlyPath(info.path()) + || VideoParams.looksAudioOnlyPath(info.rawPath()); + boolean videoOnly = VideoParams.isVideoOnly(info.params()); + return new MediaInputs(!audioOnly, !videoOnly); + } + + private record MediaInputs(boolean video, boolean audio) { + } + + private void seek(Pointer ctx, long progress) { + command(ctx, "seek", Double.toString(Math.max(0, progress) / 1000.0), "absolute", "exact"); + } + + private long createSharedWindow() { + long share = Minecraft.getInstance().getWindow().handle(); + glfwDefaultWindowHints(); + try { + glfwWindowHint(GLFW_VISIBLE, GLFW_FALSE); + glfwWindowHint(GLFW_CLIENT_API, GLFW_OPENGL_API); + glfwWindowHint(GLFW_CONTEXT_CREATION_API, GLFW_NATIVE_CONTEXT_API); + glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3); + glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); + glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); + glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE); + + long window = glfwCreateWindow(1, 1, "VideoPlayer MPV", NULL, share); + if (window == NULL) { + PointerBuffer description = BufferUtils.createPointerBuffer(1); + int error = glfwGetError(description); + long descriptionAddress = description.get(0); + String detail = descriptionAddress == NULL ? "unknown GLFW error" : memUTF8(descriptionAddress); + throw new IllegalStateException("Failed to create shared MPV OpenGL context (GLFW " + error + ": " + detail + ")."); + } + return window; + } finally { + glfwDefaultWindowHints(); + } + } + + private void renderLoop(CompletableFuture ready) { + try { + glfwMakeContextCurrent(sharedWindow); + GL.createCapabilities(); + initTexture(); + createRenderContext(); + notifySize(width, height); + ready.complete(null); + flushPendingPlay(); + + while (!released.get() && !renderFailed) { + boolean shouldRender = false; + if (renderUpdate.getAndSet(false)) { + long flags = lib.mpv_render_context_update(renderContext); + shouldRender = (flags & MPV_RENDER_UPDATE_FRAME) != 0; + } + + applyPendingSize(); + + if (shouldRender && acceptingFrames.get() && !released.get()) { + renderFrame(); + } + + synchronized (renderLock) { + if (!released.get() && !renderFailed && !renderUpdate.get()) { + renderLock.wait(16); + } + } + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + if (!ready.isDone()) ready.completeExceptionally(e); + } catch (Throwable t) { + renderFailed = true; + if (!ready.isDone()) { + ready.completeExceptionally(t); + } else if (!released.get()) { + VideoPlayerMain.LOGGER.error("MPV render thread failed; disabling MPV rendering.", t); + } + } finally { + acceptingFrames.set(false); + freeRenderContext(); + cleanupTexture(); + GL.setCapabilities(null); + glfwMakeContextCurrent(NULL); + long window = sharedWindow; + sharedWindow = NULL; + if (window != NULL) { + destroySharedWindow(window); + } + renderThreadStopped.set(true); + signalRenderThread(); + Pointer ctx = handle; + if (ctx != null) lib.mpv_wakeup(ctx); + } + } + + private void notifySize(int w, int h) { + Minecraft.getInstance().execute(() -> sizeListener.accept(w, h)); + } + + private void signalRenderThread() { + synchronized (renderLock) { + renderLock.notifyAll(); + } + } + + private void destroySharedWindow(long window) { + Minecraft client = Minecraft.getInstance(); + Runnable destroy = () -> glfwDestroyWindow(window); + if (client.isSameThread()) { + destroy.run(); + } else { + client.execute(destroy); + } + } + + private void freeRenderContext() { + Pointer ctx = renderContext; + if (ctx == null) return; + try { + lib.mpv_render_context_set_update_callback(ctx, null, null); + lib.mpv_render_context_free(ctx); + } catch (RuntimeException e) { + VideoPlayerMain.LOGGER.warn("Failed to free MPV render context", e); + } finally { + renderContext = null; + } + } + + private void flushPendingPlay() { + if (renderContext == null || released.get()) return; + + VideoInfo info; + long startTime; + int startVolume; + synchronized (this) { + if (!pendingPlay || pendingInfo == null) return; + info = pendingInfo; + startTime = pendingTargetTime; + startVolume = pendingVolume; + pendingInfo = null; + pendingPlay = false; + } + + submit(ctx -> startPlayback(ctx, info, startTime, startVolume)); + } + + private void createRenderContext() { + glProcCallback = (context, name) -> { + long address = GL.getFunctionProvider().getFunctionAddress(name); + return isInvalidProcAddress(address) ? null : Pointer.createConstant(address); + }; + + glInitParams = new MpvOpenGLInitParams(); + glInitParams.get_proc_address = glProcCallback; + glInitParams.get_proc_address_ctx = null; + glInitParams.write(); + + Memory apiType = utf8("opengl"); + Memory advancedControl = intMemory(1); + + MpvRenderParam[] params = renderParams(4); + params[0].type = MPV_RENDER_PARAM_API_TYPE; + params[0].data = apiType; + params[1].type = MPV_RENDER_PARAM_OPENGL_INIT_PARAMS; + params[1].data = glInitParams.getPointer(); + params[2].type = MPV_RENDER_PARAM_ADVANCED_CONTROL; + params[2].data = advancedControl; + params[3].type = MPV_RENDER_PARAM_INVALID; + params[3].data = null; + writeParams(params); + + PointerByReference result = new PointerByReference(); + check(lib.mpv_render_context_create(result, handle, params[0].getPointer()), "mpv_render_context_create"); + renderContext = result.getValue(); + if (renderContext == null) throw new IllegalStateException("mpv_render_context_create returned null"); + + updateCallback = callbackCtx -> { + renderUpdate.set(true); + signalRenderThread(); + }; + lib.mpv_render_context_set_update_callback(renderContext, updateCallback, null); + } + + private void initTexture() { + for (int i = 0; i < textureCount(); i++) { + textureIds[i] = glGenTextures(); + fboIds[i] = glGenFramebuffers(); + } + resizeTexture(INITIAL_SIZE, INITIAL_SIZE); + } + + private void applyPendingSize() { + int targetWidth = Math.max(1, pendingWidth); + int targetHeight = Math.max(1, pendingHeight); + if (!VideoFrameLimits.valid(targetWidth, targetHeight)) { + renderFailed = true; + pendingWidth = INITIAL_SIZE; + pendingHeight = INITIAL_SIZE; + return; + } + if (targetWidth == width && targetHeight == height) return; + resizeTexture(targetWidth, targetHeight); + notifySize(width, height); + } + + private void resizeTexture(int targetWidth, int targetHeight) { + width = targetWidth; + height = targetHeight; + + int previousActiveTexture = glGetInteger(GL_ACTIVE_TEXTURE); + glActiveTexture(GL_TEXTURE0); + int previousTexture = glGetInteger(GL_TEXTURE_BINDING_2D); + int previousReadFramebuffer = glGetInteger(GL_READ_FRAMEBUFFER_BINDING); + int previousDrawFramebuffer = glGetInteger(GL_DRAW_FRAMEBUFFER_BINDING); + try { + for (int i = 0; i < textureCount(); i++) { + glBindTexture(GL_TEXTURE_2D, textureIds[i]); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, (java.nio.ByteBuffer) null); + + glBindFramebuffer(GL_FRAMEBUFFER, fboIds[i]); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, textureIds[i], 0); + int status = glCheckFramebufferStatus(GL_FRAMEBUFFER); + if (status != GL_FRAMEBUFFER_COMPLETE) { + throw new IllegalStateException("MPV framebuffer is incomplete: 0x" + Integer.toHexString(status)); + } + } + clearPublishedTexture(); + renderTextureIndex = 1; + } finally { + glBindFramebuffer(GL_READ_FRAMEBUFFER, previousReadFramebuffer); + glBindFramebuffer(GL_DRAW_FRAMEBUFFER, previousDrawFramebuffer); + glBindTexture(GL_TEXTURE_2D, previousTexture); + glActiveTexture(previousActiveTexture); + } + } + + private void renderFrame() { + if (!acceptingFrames.get() || released.get()) return; + int textureIndex = nextRenderTextureIndex(); + renderFrameToTexture(textureIndex); + long readySync = glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0); + glFlush(); + if (!publishRenderedTexture(textureIds[textureIndex], readySync)) { + glDeleteSync(readySync); + } + } + + private void renderFrameSingleContext() { + int textureIndex = SINGLE_CONTEXT_TEXTURE_INDEX; + renderFrameToTexture(textureIndex); + glFlush(); + displayTextureId = textureIds[textureIndex]; + } + + private int textureCount() { + return singleContext ? 1 : SHARED_TEXTURE_COUNT; + } + + private void renderFrameToTexture(int textureIndex) { + MpvOpenGLFbo target = new MpvOpenGLFbo(); + target.fbo = fboIds[textureIndex]; + target.w = width; + target.h = height; + target.internal_format = GL_RGBA8; + target.write(); + + Memory flipY = intMemory(0); + Memory blockForTargetTime = intMemory(0); + + MpvRenderParam[] params = renderParams(4); + params[0].type = MPV_RENDER_PARAM_OPENGL_FBO; + params[0].data = target.getPointer(); + params[1].type = MPV_RENDER_PARAM_FLIP_Y; + params[1].data = flipY; + params[2].type = MPV_RENDER_PARAM_BLOCK_FOR_TARGET_TIME; + params[2].data = blockForTargetTime; + params[3].type = MPV_RENDER_PARAM_INVALID; + params[3].data = null; + writeParams(params); + + GlStateSnapshot snapshot = singleContext ? GlStateSnapshot.capture() : null; + try { + prepareGlStateForMpv(); + check(lib.mpv_render_context_render(renderContext, params[0].getPointer()), "mpv_render_context_render"); + } finally { + if (snapshot != null) snapshot.restore(); + } + } + + private boolean publishRenderedTexture(int readyTextureId, long readySync) { + if (!acceptingFrames.get() || released.get()) { + return false; + } + long previousSync; + synchronized (publishLock) { + if (!acceptingFrames.get() || released.get()) { + return false; + } + previousSync = pendingReadySync; + pendingReadySync = readySync; + publishedTextureId = readyTextureId; + } + if (previousSync != NULL) { + glDeleteSync(previousSync); + } + return true; + } + + private int nextRenderTextureIndex() { + int displayedTexture = displayTextureId; + int pendingTexture; + synchronized (publishLock) { + pendingTexture = publishedTextureId; + } + for (int i = 0; i < SHARED_TEXTURE_COUNT; i++) { + int index = (renderTextureIndex + i) % SHARED_TEXTURE_COUNT; + if (textureIds[index] >= 0 && textureIds[index] != displayedTexture && textureIds[index] != pendingTexture) { + renderTextureIndex = (index + 1) % SHARED_TEXTURE_COUNT; + return index; + } + } + int index = renderTextureIndex; + renderTextureIndex = (renderTextureIndex + 1) % SHARED_TEXTURE_COUNT; + return index; + } + + private static boolean isInvalidProcAddress(long address) { + return address == 0 || address == 1 || address == 2 || address == 3 || address == -1L; + } + + private void prepareGlStateForMpv() { + glUseProgram(0); + glBindVertexArray(0); + glBindBuffer(GL_ARRAY_BUFFER, 0); + glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0); + glBindBuffer(GL_PIXEL_PACK_BUFFER, 0); + glBindBuffer(GL_PIXEL_UNPACK_BUFFER, 0); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, 0); + glDisable(GL_BLEND); + glDisable(GL_SCISSOR_TEST); + glDisable(GL_CULL_FACE); + glDisable(GL_DEPTH_TEST); + glDisable(GL_FRAMEBUFFER_SRGB); + glColorMask(true, true, true, true); + glDepthMask(true); + resetPixelStore(); + glViewport(0, 0, width, height); + } + + private void resetPixelStore() { + glPixelStorei(GL_PACK_ALIGNMENT, 4); + glPixelStorei(GL_PACK_ROW_LENGTH, 0); + glPixelStorei(GL_PACK_SKIP_PIXELS, 0); + glPixelStorei(GL_PACK_SKIP_ROWS, 0); + glPixelStorei(GL_PACK_SWAP_BYTES, 0); + glPixelStorei(GL_PACK_LSB_FIRST, 0); + glPixelStorei(GL_UNPACK_ALIGNMENT, 4); + glPixelStorei(GL_UNPACK_ROW_LENGTH, 0); + glPixelStorei(GL_UNPACK_SKIP_PIXELS, 0); + glPixelStorei(GL_UNPACK_SKIP_ROWS, 0); + glPixelStorei(GL_UNPACK_IMAGE_HEIGHT, 0); + glPixelStorei(GL_UNPACK_SKIP_IMAGES, 0); + glPixelStorei(GL_UNPACK_SWAP_BYTES, 0); + glPixelStorei(GL_UNPACK_LSB_FIRST, 0); + } + + private static void setEnabled(int capability, boolean enabled) { + if (enabled) { + glEnable(capability); + } else { + glDisable(capability); + } + } + + private static class GlStateSnapshot { + private static final int PIXEL_STORE_COUNT = 14; + + private final int activeTexture; + private final int texture0Binding; + private final int activeTextureBinding; + private final int readFramebuffer; + private final int drawFramebuffer; + private final int program; + private final int vertexArray; + private final int arrayBuffer; + private final int elementArrayBuffer; + private final int pixelPackBuffer; + private final int pixelUnpackBuffer; + private final int[] viewport; + private final int[] pixelStore; + private final boolean blend; + private final boolean scissor; + private final boolean cullFace; + private final boolean depthTest; + private final boolean framebufferSrgb; + private final boolean depthMask; + private final boolean[] colorMask; + + private GlStateSnapshot( + int activeTexture, + int texture0Binding, + int activeTextureBinding, + int readFramebuffer, + int drawFramebuffer, + int program, + int vertexArray, + int arrayBuffer, + int elementArrayBuffer, + int pixelPackBuffer, + int pixelUnpackBuffer, + int[] viewport, + int[] pixelStore, + boolean blend, + boolean scissor, + boolean cullFace, + boolean depthTest, + boolean framebufferSrgb, + boolean depthMask, + boolean[] colorMask + ) { + this.activeTexture = activeTexture; + this.texture0Binding = texture0Binding; + this.activeTextureBinding = activeTextureBinding; + this.readFramebuffer = readFramebuffer; + this.drawFramebuffer = drawFramebuffer; + this.program = program; + this.vertexArray = vertexArray; + this.arrayBuffer = arrayBuffer; + this.elementArrayBuffer = elementArrayBuffer; + this.pixelPackBuffer = pixelPackBuffer; + this.pixelUnpackBuffer = pixelUnpackBuffer; + this.viewport = viewport; + this.pixelStore = pixelStore; + this.blend = blend; + this.scissor = scissor; + this.cullFace = cullFace; + this.depthTest = depthTest; + this.framebufferSrgb = framebufferSrgb; + this.depthMask = depthMask; + this.colorMask = colorMask; + } + + private static GlStateSnapshot capture() { + int activeTexture = glGetInteger(GL_ACTIVE_TEXTURE); + glActiveTexture(GL_TEXTURE0); + int texture0Binding = glGetInteger(GL_TEXTURE_BINDING_2D); + glActiveTexture(activeTexture); + int activeTextureBinding = glGetInteger(GL_TEXTURE_BINDING_2D); + + return new GlStateSnapshot( + activeTexture, + texture0Binding, + activeTextureBinding, + glGetInteger(GL_READ_FRAMEBUFFER_BINDING), + glGetInteger(GL_DRAW_FRAMEBUFFER_BINDING), + glGetInteger(GL_CURRENT_PROGRAM), + glGetInteger(GL_VERTEX_ARRAY_BINDING), + glGetInteger(GL_ARRAY_BUFFER_BINDING), + glGetInteger(GL_ELEMENT_ARRAY_BUFFER_BINDING), + glGetInteger(GL_PIXEL_PACK_BUFFER_BINDING), + glGetInteger(GL_PIXEL_UNPACK_BUFFER_BINDING), + getIntVector(GL_VIEWPORT, 4), + capturePixelStore(), + glIsEnabled(GL_BLEND), + glIsEnabled(GL_SCISSOR_TEST), + glIsEnabled(GL_CULL_FACE), + glIsEnabled(GL_DEPTH_TEST), + glIsEnabled(GL_FRAMEBUFFER_SRGB), + getBoolean(GL_DEPTH_WRITEMASK), + getBooleanVector(GL_COLOR_WRITEMASK, 4) + ); + } + + private void restore() { + glUseProgram(program); + glBindVertexArray(vertexArray); + glBindBuffer(GL_ARRAY_BUFFER, arrayBuffer); + glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, elementArrayBuffer); + glBindBuffer(GL_PIXEL_PACK_BUFFER, pixelPackBuffer); + glBindBuffer(GL_PIXEL_UNPACK_BUFFER, pixelUnpackBuffer); + glBindFramebuffer(GL_READ_FRAMEBUFFER, readFramebuffer); + glBindFramebuffer(GL_DRAW_FRAMEBUFFER, drawFramebuffer); + glViewport(viewport[0], viewport[1], viewport[2], viewport[3]); + setEnabled(GL_BLEND, blend); + setEnabled(GL_SCISSOR_TEST, scissor); + setEnabled(GL_CULL_FACE, cullFace); + setEnabled(GL_DEPTH_TEST, depthTest); + setEnabled(GL_FRAMEBUFFER_SRGB, framebufferSrgb); + glColorMask(colorMask[0], colorMask[1], colorMask[2], colorMask[3]); + glDepthMask(depthMask); + restorePixelStore(); + + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, texture0Binding); + glActiveTexture(activeTexture); + glBindTexture(GL_TEXTURE_2D, activeTextureBinding); + } + + private static int[] capturePixelStore() { + int[] state = new int[PIXEL_STORE_COUNT]; + state[0] = glGetInteger(GL_PACK_ALIGNMENT); + state[1] = glGetInteger(GL_PACK_ROW_LENGTH); + state[2] = glGetInteger(GL_PACK_SKIP_PIXELS); + state[3] = glGetInteger(GL_PACK_SKIP_ROWS); + state[4] = glGetInteger(GL_PACK_SWAP_BYTES); + state[5] = glGetInteger(GL_PACK_LSB_FIRST); + state[6] = glGetInteger(GL_UNPACK_ALIGNMENT); + state[7] = glGetInteger(GL_UNPACK_ROW_LENGTH); + state[8] = glGetInteger(GL_UNPACK_SKIP_PIXELS); + state[9] = glGetInteger(GL_UNPACK_SKIP_ROWS); + state[10] = glGetInteger(GL_UNPACK_IMAGE_HEIGHT); + state[11] = glGetInteger(GL_UNPACK_SKIP_IMAGES); + state[12] = glGetInteger(GL_UNPACK_SWAP_BYTES); + state[13] = glGetInteger(GL_UNPACK_LSB_FIRST); + return state; + } + + private void restorePixelStore() { + glPixelStorei(GL_PACK_ALIGNMENT, pixelStore[0]); + glPixelStorei(GL_PACK_ROW_LENGTH, pixelStore[1]); + glPixelStorei(GL_PACK_SKIP_PIXELS, pixelStore[2]); + glPixelStorei(GL_PACK_SKIP_ROWS, pixelStore[3]); + glPixelStorei(GL_PACK_SWAP_BYTES, pixelStore[4]); + glPixelStorei(GL_PACK_LSB_FIRST, pixelStore[5]); + glPixelStorei(GL_UNPACK_ALIGNMENT, pixelStore[6]); + glPixelStorei(GL_UNPACK_ROW_LENGTH, pixelStore[7]); + glPixelStorei(GL_UNPACK_SKIP_PIXELS, pixelStore[8]); + glPixelStorei(GL_UNPACK_SKIP_ROWS, pixelStore[9]); + glPixelStorei(GL_UNPACK_IMAGE_HEIGHT, pixelStore[10]); + glPixelStorei(GL_UNPACK_SKIP_IMAGES, pixelStore[11]); + glPixelStorei(GL_UNPACK_SWAP_BYTES, pixelStore[12]); + glPixelStorei(GL_UNPACK_LSB_FIRST, pixelStore[13]); + } + + private static int[] getIntVector(int name, int size) { + IntBuffer buffer = BufferUtils.createIntBuffer(size); + glGetIntegerv(name, buffer); + int[] values = new int[size]; + buffer.get(values); + return values; + } + + private static boolean getBoolean(int name) { + ByteBuffer buffer = BufferUtils.createByteBuffer(1); + glGetBooleanv(name, buffer); + return buffer.get(0) != 0; + } + + private static boolean[] getBooleanVector(int name, int size) { + ByteBuffer buffer = BufferUtils.createByteBuffer(size); + glGetBooleanv(name, buffer); + boolean[] values = new boolean[size]; + for (int i = 0; i < size; i++) { + values[i] = buffer.get(i) != 0; + } + return values; + } + } + + private void cleanupTexture() { + clearPublishedTexture(); + for (int i = 0; i < SHARED_TEXTURE_COUNT; i++) { + if (fboIds[i] >= 0) { + glDeleteFramebuffers(fboIds[i]); + fboIds[i] = -1; + } + if (textureIds[i] >= 0) { + ScreenRenderer.releaseTexture(textureIds[i]); + glDeleteTextures(textureIds[i]); + textureIds[i] = -1; + } + } + renderTextureIndex = 0; + } + + private void releaseRegisteredTextures() { + for (int textureId : textureIds) { + ScreenRenderer.releaseTexture(textureId); + } + } + + private void clearPublishedTexture() { + long sync; + synchronized (publishLock) { + sync = pendingReadySync; + pendingReadySync = NULL; + publishedTextureId = -1; + displayTextureId = -1; + } + if (sync != NULL) { + glDeleteSync(sync); + } + } + + private void command(Pointer ctx, String... args) { + ArrayList strings = new ArrayList<>(args.length); + Memory argv = new Memory((long) (args.length + 1) * Native.POINTER_SIZE); + for (int i = 0; i < args.length; i++) { + Memory string = utf8(args[i]); + strings.add(string); + argv.setPointer((long) i * Native.POINTER_SIZE, string); + } + argv.setPointer((long) args.length * Native.POINTER_SIZE, null); + check(lib.mpv_command(ctx, argv), "mpv_command " + args[0]); + } + + private void loadFile(Pointer ctx, String path, String loadOptions) { + if (loadOptions == null || loadOptions.isEmpty()) { + command(ctx, "loadfile", path, "replace"); + return; + } + command(ctx, "loadfile", path, "replace", "-1", loadOptions); + } + + private void stopNativePlayback(Pointer ctx) { + if (ctx == null) return; + try { + command(ctx, "stop"); + } catch (RuntimeException e) { + if (!released.get()) { + VideoPlayerMain.LOGGER.warn("Failed to stop MPV playback", e); + } + } + } + + private void setString(Pointer ctx, String name, String value) { + Memory string = utf8(value); + Memory pointer = new Memory(Native.POINTER_SIZE); + pointer.setPointer(0, string); + check(lib.mpv_set_property(ctx, name, MPV_FORMAT_STRING, pointer), "mpv_set_property " + name); + } + + private void setOptionString(Pointer ctx, String name, String value) { + check(lib.mpv_set_option_string(ctx, name, value), "mpv_set_option_string " + name); + } + + private void setFlag(Pointer ctx, String name, boolean value) { + Memory data = intMemory(value ? 1 : 0); + check(lib.mpv_set_property(ctx, name, MPV_FORMAT_FLAG, data), "mpv_set_property " + name); + } + + private void setDouble(Pointer ctx, String name, double value) { + Memory data = new Memory(Double.BYTES); + data.setDouble(0, value); + check(lib.mpv_set_property(ctx, name, MPV_FORMAT_DOUBLE, data), "mpv_set_property " + name); + } + + private Double getDouble(Pointer ctx, String name) { + Memory data = new Memory(Double.BYTES); + int result = lib.mpv_get_property(ctx, name, MPV_FORMAT_DOUBLE, data); + return result < 0 ? null : data.getDouble(0); + } + + private Boolean getFlag(Pointer ctx, String name) { + Memory data = intMemory(0); + int result = lib.mpv_get_property(ctx, name, MPV_FORMAT_FLAG, data); + return result < 0 ? null : data.getInt(0) != 0; + } + + private String getString(Pointer ctx, String name) { + PointerByReference reference = new PointerByReference(); + int result = lib.mpv_get_property(ctx, name, MPV_FORMAT_STRING, reference.getPointer()); + if (result < 0) return null; + Pointer value = reference.getValue(); + if (value == null) return null; + try { + return value.getString(0, StandardCharsets.UTF_8.name()); + } finally { + lib.mpv_free(value); + } + } + + private static Memory intMemory(int value) { + Memory data = new Memory(Integer.BYTES); + data.setInt(0, value); + return data; + } + + private MpvRenderParam[] renderParams(int count) { + return (MpvRenderParam[]) new MpvRenderParam().toArray(count); + } + + private void writeParams(MpvRenderParam[] params) { + for (MpvRenderParam param : params) { + param.write(); + } + } + + private void check(int result, String operation) { + if (result >= 0) return; + throw new IllegalStateException(operation + " failed: " + lib.mpv_error_string(result)); + } + + @FunctionalInterface + private interface MpvTask { + void run(Pointer ctx); + } +} diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/PBOManager.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/PBOManager.java new file mode 100644 index 0000000..4252d67 --- /dev/null +++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/PBOManager.java @@ -0,0 +1,228 @@ +package com.github.squi2rel.vp.video; + +import com.github.squi2rel.vp.VideoPlayerMain; +import org.lwjgl.opengl.GL; +import org.lwjgl.opengl.GLCapabilities; + +import java.util.Arrays; +import java.nio.ByteBuffer; +import java.util.concurrent.locks.ReentrantLock; +import net.minecraft.client.Minecraft; + +import static org.lwjgl.opengl.ARBBufferStorage.GL_DYNAMIC_STORAGE_BIT; +import static org.lwjgl.opengl.ARBBufferStorage.GL_MAP_COHERENT_BIT; +import static org.lwjgl.opengl.ARBBufferStorage.GL_MAP_PERSISTENT_BIT; +import static org.lwjgl.opengl.ARBBufferStorage.glBufferStorage; +import static org.lwjgl.opengl.GL15C.GL_STREAM_DRAW; +import static org.lwjgl.opengl.GL21C.GL_PIXEL_UNPACK_BUFFER; +import static org.lwjgl.opengl.GL21C.GL_PIXEL_UNPACK_BUFFER_BINDING; +import static org.lwjgl.opengl.GL21C.glBindBuffer; +import static org.lwjgl.opengl.GL21C.glBufferData; +import static org.lwjgl.opengl.GL21C.glDeleteBuffers; +import static org.lwjgl.opengl.GL21C.glGenBuffers; +import static org.lwjgl.opengl.GL21C.glGetInteger; +import static org.lwjgl.opengl.GL21C.glUnmapBuffer; +import static org.lwjgl.opengl.GL30C.GL_MAP_INVALIDATE_BUFFER_BIT; +import static org.lwjgl.opengl.GL30C.GL_MAP_WRITE_BIT; +import static org.lwjgl.opengl.GL30C.glMapBufferRange; +import static org.lwjgl.opengl.GL32C.GL_ALREADY_SIGNALED; +import static org.lwjgl.opengl.GL32C.GL_CONDITION_SATISFIED; +import static org.lwjgl.opengl.GL32C.GL_SYNC_FLUSH_COMMANDS_BIT; +import static org.lwjgl.opengl.GL32C.GL_SYNC_GPU_COMMANDS_COMPLETE; +import static org.lwjgl.opengl.GL32C.GL_TIMEOUT_EXPIRED; +import static org.lwjgl.opengl.GL32C.GL_WAIT_FAILED; +import static org.lwjgl.opengl.GL32C.glClientWaitSync; +import static org.lwjgl.opengl.GL32C.glDeleteSync; +import static org.lwjgl.opengl.GL32C.glFenceSync; + +public class PBOManager { + private static final int BUFFER_COUNT = 3; + private static Boolean persistentSupported; + + private final int[] id = new int[BUFFER_COUNT]; + private final long[] fences = new long[BUFFER_COUNT]; + private final ByteBuffer[] persistentBuffers = new ByteBuffer[BUFFER_COUNT]; + + private boolean allocated = false; + private boolean persistent = false; + private int next = 0; + private int uploading = -1; + private int bufferSize; + private ByteBuffer mapBuffer; + private final ReentrantLock lock = new ReentrantLock(); + + private static boolean supportsPersistentMapping() { + if (persistentSupported != null) return persistentSupported; + GLCapabilities capabilities = GL.getCapabilities(); + persistentSupported = capabilities.OpenGL44 || capabilities.GL_ARB_buffer_storage; + return persistentSupported; + } + + public void init(int width, int height) { + int nextBufferSize = VideoFrameLimits.rgbaBytes(width, height); + lock.lock(); + try { + int prevPBO = glGetInteger(GL_PIXEL_UNPACK_BUFFER_BINDING); + if (allocated) { + destroyCurrent(); + } + glGenBuffers(id); + allocated = true; + bufferSize = nextBufferSize; + persistent = supportsPersistentMapping() && initPersistent(); + if (!persistent) { + initStreaming(); + } + glBindBuffer(GL_PIXEL_UNPACK_BUFFER, prevPBO); + } finally { + lock.unlock(); + } + } + + public void upload(ByteBuffer source, Runnable textureUpload) { + lock.lock(); + try { + if (!allocated || source.remaining() != bufferSize) return; + uploading = next; + next = (next + 1) % BUFFER_COUNT; + if (persistent && !waitForFence(uploading)) return; + + glBindBuffer(GL_PIXEL_UNPACK_BUFFER, id[uploading]); + ByteBuffer target = mapForUpload(); + if (target == null) return; + + target.clear(); + target.put(source.slice()); + if (!persistent) { + glUnmapBuffer(GL_PIXEL_UNPACK_BUFFER); + } + + textureUpload.run(); + if (persistent) { + fences[uploading] = glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0); + } + } finally { + uploading = -1; + lock.unlock(); + } + } + + private ByteBuffer mapForUpload() { + if (persistent) { + return persistentBuffers[uploading]; + } + if (mapBuffer == null || mapBuffer.capacity() != bufferSize) { + mapBuffer = ByteBuffer.allocateDirect(bufferSize); + } + return glMapBufferRange(GL_PIXEL_UNPACK_BUFFER, 0, bufferSize, GL_MAP_WRITE_BIT | GL_MAP_INVALIDATE_BUFFER_BIT, mapBuffer); + } + + public void release() { + try { + lock.lock(); + if (!allocated) return; + BufferState state = detach(); + Minecraft.getInstance().execute(() -> destroy(state)); + } finally { + lock.unlock(); + } + } + + private boolean initPersistent() { + int flags = GL_MAP_WRITE_BIT | GL_MAP_PERSISTENT_BIT | GL_MAP_COHERENT_BIT; + try { + for (int buffer : id) { + glBindBuffer(GL_PIXEL_UNPACK_BUFFER, buffer); + glBufferStorage(GL_PIXEL_UNPACK_BUFFER, bufferSize, flags | GL_DYNAMIC_STORAGE_BIT); + ByteBuffer mapped = glMapBufferRange(GL_PIXEL_UNPACK_BUFFER, 0, bufferSize, flags); + if (mapped == null) { + destroyCurrent(); + return false; + } + persistentBuffers[indexOf(buffer)] = mapped; + } + return true; + } catch (RuntimeException e) { + VideoPlayerMain.LOGGER.warn("Persistent PBO mapping is unavailable, falling back to streaming PBO", e); + destroyCurrent(); + return false; + } + } + + private int indexOf(int buffer) { + for (int i = 0; i < id.length; i++) { + if (id[i] == buffer) return i; + } + throw new IllegalArgumentException("Unknown PBO id: " + buffer); + } + + private void initStreaming() { + for (int buffer : id) { + glBindBuffer(GL_PIXEL_UNPACK_BUFFER, buffer); + glBufferData(GL_PIXEL_UNPACK_BUFFER, bufferSize, GL_STREAM_DRAW); + } + mapBuffer = ByteBuffer.allocateDirect(bufferSize); + } + + private boolean waitForFence(int index) { + long fence = fences[index]; + if (fence == 0) return true; + int result = glClientWaitSync(fence, 0, 0); + if (result == GL_TIMEOUT_EXPIRED) { + result = glClientWaitSync(fence, GL_SYNC_FLUSH_COMMANDS_BIT, 1_000_000L); + } + if (result == GL_ALREADY_SIGNALED || result == GL_CONDITION_SATISFIED) { + glDeleteSync(fence); + fences[index] = 0; + return true; + } + if (result == GL_WAIT_FAILED) { + glDeleteSync(fence); + fences[index] = 0; + } + return false; + } + + private BufferState detach() { + BufferState state = new BufferState(Arrays.copyOf(id, id.length), Arrays.copyOf(fences, fences.length), persistent); + Arrays.fill(id, 0); + Arrays.fill(fences, 0); + Arrays.fill(persistentBuffers, null); + allocated = false; + persistent = false; + next = 0; + uploading = -1; + bufferSize = 0; + mapBuffer = null; + return state; + } + + private void destroyCurrent() { + destroy(detach()); + } + + private void destroy(BufferState state) { + int prevPBO = glGetInteger(GL_PIXEL_UNPACK_BUFFER_BINDING); + for (long fence : state.fences) { + if (fence != 0) { + glDeleteSync(fence); + } + } + if (state.persistent) { + for (int buffer : state.ids) { + if (buffer == 0) continue; + glBindBuffer(GL_PIXEL_UNPACK_BUFFER, buffer); + glUnmapBuffer(GL_PIXEL_UNPACK_BUFFER); + } + } + glBindBuffer(GL_PIXEL_UNPACK_BUFFER, prevPBO); + glDeleteBuffers(state.ids); + } + + public boolean allocated() { + return allocated; + } + + private record BufferState(int[] ids, long[] fences, boolean persistent) { + } +} diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/UnavailableVideoBackend.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/UnavailableVideoBackend.java new file mode 100644 index 0000000..f2c35d4 --- /dev/null +++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/UnavailableVideoBackend.java @@ -0,0 +1,115 @@ +package com.github.squi2rel.vp.video; + +import com.github.squi2rel.vp.i18n.VpTexts; +import com.github.squi2rel.vp.provider.VideoInfo; +import net.minecraft.ChatFormatting; +import net.minecraft.client.Minecraft; +import net.minecraft.client.player.LocalPlayer; + +import static com.github.squi2rel.vp.VideoPlayerMain.LOGGER; + +final class UnavailableVideoBackend implements VideoBackend { + private final String requestedBackend; + private boolean warned; + + UnavailableVideoBackend(String requestedBackend) { + this.requestedBackend = VideoBackends.normalize(requestedBackend); + } + + @Override + public String name() { + return requestedBackend; + } + + @Override + public void init() { + } + + @Override + public void play(VideoInfo info, long targetTime, int volume) { + if (warned) return; + warned = true; + LOGGER.warn("No available video backend for requested backend {}", requestedBackend); + Minecraft client = Minecraft.getInstance(); + LocalPlayer player = client == null ? null : client.player; + if (player != null) { + player.sendSystemMessage(VpTexts.tr( + "error.videoplayer.local_backend_unavailable", + "Neither local MPV nor VLC is available. Open /videoplayer boot to install or repair a video runtime." + ).withStyle(ChatFormatting.RED)); + } + } + + @Override + public void updateTexture() { + } + + @Override + public int getTextureId() { + return -1; + } + + @Override + public int getWidth() { + return 1; + } + + @Override + public int getHeight() { + return 1; + } + + @Override + public void stop() { + } + + @Override + public boolean canPause() { + return false; + } + + @Override + public void pause(boolean pause) { + } + + @Override + public boolean isPaused() { + return true; + } + + @Override + public void setVolume(int volume) { + } + + @Override + public boolean canSetProgress() { + return false; + } + + @Override + public void setProgress(long progress) { + } + + @Override + public long getProgress() { + return 0; + } + + @Override + public long getTotalProgress() { + return 0; + } + + @Override + public void setRate(float rate) { + } + + @Override + public float getRate() { + return 1; + } + + @Override + public void cleanup() { + } +} diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/VideoPlayer.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/VideoPlayer.java new file mode 100644 index 0000000..b3c8a25 --- /dev/null +++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/VideoPlayer.java @@ -0,0 +1,272 @@ +package com.github.squi2rel.vp.video; + +import com.github.squi2rel.vp.provider.VideoInfo; +import com.github.squi2rel.vp.render.WorldRenderBatch; +import com.github.squi2rel.vp.vivecraft.Vivecraft; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import org.joml.Matrix4f; +import org.joml.Vector2f; +import org.joml.Vector3f; + +import static com.github.squi2rel.vp.VideoPlayerMain.LOGGER; + + +import static com.github.squi2rel.vp.VideoPlayerClient.config; + +public class VideoPlayer extends AbstractScreenPlayer implements RateAdjustablePlayer, MetaListener { + protected VideoBackend backend; + protected boolean initialized = false; + protected long targetTime = -1; + protected boolean is3d = false; + private Integer outputVolumeOverride; + public int videoWidth, videoHeight; + private String requestedBackend = VideoBackends.VLC; + private final java.util.function.BiConsumer sizeListener = (w, h) -> { + videoWidth = w; + videoHeight = h; + }; + + public VideoPlayer(ClientVideoScreen screen) { + super(screen); + } + + @Override + public void updateTexture() { + if (!initialized) return; + backend.updateTexture(); + } + + @Override + public synchronized void init() { + if (initialized) throw new IllegalStateException("already initialized"); + + requestedBackend = config == null ? VideoBackends.VLC : VideoBackends.normalize(config.videoBackend); + backend = VideoBackends.create(requestedBackend, sizeListener); + try { + backend.init(); + } catch (Throwable e) { + if (VideoBackends.MPV.equals(backend.name())) { + LOGGER.warn("Failed to initialize MPV backend. Falling back to VLC.", e); + backend.cleanup(); + backend = VideoBackends.createVlc(sizeListener); + try { + backend.init(); + } catch (Throwable fallbackError) { + LOGGER.warn("Failed to initialize VLC fallback backend.", fallbackError); + backend.cleanup(); + backend = new UnavailableVideoBackend(requestedBackend); + backend.init(); + } + } else { + LOGGER.warn("Failed to initialize video backend {}.", backend.name(), e); + backend.cleanup(); + backend = new UnavailableVideoBackend(requestedBackend); + backend.init(); + } + } + + initialized = true; + } + + @Override + public int getWidth() { + return is3d ? videoWidth / 2 : videoWidth; + } + + @Override + public int getHeight() { + return videoHeight; + } + + @Override + public void play(VideoInfo info) { + backend.play(info, targetTime, backendVolume()); + } + + @Override + public int getTextureId() { + if (initialized) { + return backend.getTextureId(); + } + return -1; + } + + @Override + public boolean hasVideoFrame() { + return initialized && backend.hasVideoFrame(); + } + + @Override + public void stop() { + backend.stop(); + } + + @Override + public boolean canPause() { + return backend.canPause(); + } + + @Override + public void pause(boolean pause) { + backend.pause(pause); + } + + @Override + public boolean isPaused() { + return backend.isPaused(); + } + + @Override + public void setVolume(int volume) { + int clamped = Math.clamp(volume, 0, 100); + if (VideoBackends.MPV.equals(backendName())) { + screen.volume = clamped; + } + if (outputVolumeOverride != null) return; + backend.setVolume(screen.metadata.getBool("mute", false) ? 0 : clamped); + } + + @Override + public void setOutputVolume(int volume) { + outputVolumeOverride = Math.clamp(volume, 0, 100); + backend.setVolume(backendVolume()); + } + + @Override + public void clearOutputVolume() { + outputVolumeOverride = null; + if (initialized && backend != null) backend.setVolume(effectiveVolume()); + } + + @Override + public AudioLevelSnapshot audioLevel() { + return backend == null ? AudioLevelSnapshot.unsupported() : backend.audioLevel(); + } + + @Override + public boolean canSetProgress() { + return backend.canSetProgress(); + } + + @Override + public void setProgress(long progress) { + backend.setProgress(progress); + } + + @Override + public long getProgress() { + return backend.getProgress(); + } + + @Override + public long getTotalProgress() { + return backend.getTotalProgress(); + } + + @Override + public void setTargetTime(long targetTime) { + this.targetTime = targetTime; + } + + @Override + public void setRate(float rate) { + backend.setRate(rate); + } + + @Override + public float getRate() { + return backend.getRate(); + } + + @Override + public synchronized void cleanup() { + initialized = false; + if (backend != null) backend.cleanup(); + } + + @Override + public void onMetaChanged() { + is3d = screen.stereo3d; + if (initialized && backend != null) { + backend.setVolume(backendVolume()); + } + } + + private int backendVolume() { + if (outputVolumeOverride == null) return effectiveVolume(); + return screen.metadata.getBool("mute", false) ? 0 : outputVolumeOverride; + } + + private int effectiveVolume() { + if (screen.metadata.getBool("mute", false)) return 0; + if (VideoBackends.MPV.equals(backendName())) return Math.clamp(screen.volume, 0, 100); + return config.volume; + } + + public String backendName() { + return backend == null ? VideoBackends.VLC : backend.name(); + } + + public String requestedBackendName() { + return requestedBackend; + } + + @Override + public boolean flippedX() { + return initialized && backend.flippedX(); + } + + @Override + public boolean flippedY() { + return initialized && backend.flippedY(); + } + + @Override + public boolean isPostUpdate() { + return initialized && backend.isPostUpdate(); + } + + @Override + public void draw(PoseStack matrices, WorldRenderBatch consumers, ClientVideoScreen s) { + VideoPlayerRenderer.draw(this, matrices, consumers, s); + if (s.surface == ScreenSurface.SPHERE_360 && s.spherePreset && getTextureId() >= 0) { + Degree360Player.drawTexture(getTextureId(), matrices, consumers, s, is3d); + } + } + + @Override + public void drawQuad(Matrix4f mat, VertexConsumer consumer, Vector3f p1, Vector3f p2, Vector3f p3, Vector3f p4, float u1, float v1, float u2, float v2) { + if (is3d) { + draw3D(mat, consumer, p1, p2, p3, p4, u1, v1, u2, v2); + return; + } + super.drawQuad(mat, consumer, p1, p2, p3, p4, u1, v1, u2, v2); + } + + public void draw3D(Matrix4f mat, VertexConsumer consumer, Vector3f p1, Vector3f p2, Vector3f p3, Vector3f p4, float u1, float v1, float u2, float v2) { + if (Vivecraft.loaded && Vivecraft.isRightEye()) { + super.drawQuad(mat, consumer, p1, p2, p3, p4, (u1 + u2) / 2, v1, u2, v2); + } else { + super.drawQuad(mat, consumer, p1, p2, p3, p4, u1, v1, (u1 + u2) / 2, v2); + } + } + + @Override + public void drawVertex(Matrix4f mat, VertexConsumer consumer, Vector3f vertex, Vector2f uv, ClientVideoScreen target) { + drawVertex(mat, consumer, vertex, uv, null, target); + } + + @Override + public void drawVertex(Matrix4f mat, VertexConsumer consumer, Vector3f vertex, Vector2f uv, Vector3f normal, ClientVideoScreen target) { + if (is3d) { + float split = (target.u1 + target.u2) * 0.5f; + if (Vivecraft.loaded && Vivecraft.isRightEye()) { + uv.x = split + (uv.x - target.u1) * 0.5f; + } else { + uv.x = target.u1 + (uv.x - target.u1) * 0.5f; + } + } + super.drawVertex(mat, consumer, vertex, uv, normal); + } +} diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/VideoPlayerRenderer.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/VideoPlayerRenderer.java new file mode 100644 index 0000000..585dc9f --- /dev/null +++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/VideoPlayerRenderer.java @@ -0,0 +1,368 @@ +package com.github.squi2rel.vp.video; + +import com.github.squi2rel.vp.ScreenRenderer; +import com.github.squi2rel.vp.render.WorldRenderBatch; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import org.joml.Matrix4f; +import org.joml.Vector2f; +import org.joml.Vector3f; + +import java.util.ArrayList; +import java.util.List; +import net.minecraft.client.renderer.rendertype.RenderType; + +import static com.github.squi2rel.vp.VideoPlayerClient.config; + +final class VideoPlayerRenderer { + private static final int BACKING_COLOR = 0xFF000000; + private static final int[] TRIANGLE_QUAD_ORDER = {0, 1, 2, 2}; + private static final int[] REVERSED_TRIANGLE_QUAD_ORDER = {0, 2, 1, 1}; + + private VideoPlayerRenderer() { + } + + static void draw(IVideoPlayer player, PoseStack matrices, WorldRenderBatch consumers, ClientVideoScreen target) { + ClientVideoScreen source = player.screen(); + if (source == null || source.player == null) return; + if (player.getTextureId() < 0) return; + + ScreenGeometry geometry; + try { + geometry = target.geometry(); + } catch (IllegalArgumentException ignored) { + return; + } + + Vector3f relativeOrigin = geometry.relativeOrigin(ScreenRenderer.preciseCameraX, ScreenRenderer.preciseCameraY, ScreenRenderer.preciseCameraZ); + matrices.pushPose(); + matrices.translate(relativeOrigin.x, relativeOrigin.y, relativeOrigin.z); + Matrix4f mat = matrices.last().pose(); + matrices.popPose(); + + boolean fx = player.flippedX(); + boolean fy = player.flippedY(); + float[] bounds = geometry.contentBounds( + target.u1, + target.v1, + target.u2, + target.v2, + target.fill, + target.scaleX, + target.scaleY, + player.getWidth(), + player.getHeight() + ); + int[] triangles = geometry.triangleIndices(); + List vertices = geometry.localVertices(); + List mappedUvs = mappedUvs(target, vertices); + Vector3f normal = geometry.normal(); + VertexConsumer backingConsumer = consumers.getBuffer(ScreenRenderer.getBackingLayer(player.getTextureId())); + for (int i = 0; i < triangles.length; i += 3) { + drawBackingTriangle(mat, backingConsumer, target, geometry, vertices, triangles, i, bounds, mappedUvs, normal); + } + + RenderType layer = ScreenRenderer.getLayer(player.getTextureId()); + VertexConsumer consumer = consumers.getBuffer(layer); + for (int i = 0; i < triangles.length; i += 3) { + drawTriangle(player, mat, consumer, target, geometry, vertices, triangles, i, bounds, fx, fy, mappedUvs, normal); + } + } + + static void drawTexture(int textureId, int textureWidth, int textureHeight, + PoseStack matrices, WorldRenderBatch consumers, ClientVideoScreen target) { + if (textureId < 0) return; + + ScreenGeometry geometry; + try { + geometry = target.geometry(); + } catch (IllegalArgumentException ignored) { + return; + } + + Vector3f relativeOrigin = geometry.relativeOrigin(ScreenRenderer.preciseCameraX, ScreenRenderer.preciseCameraY, ScreenRenderer.preciseCameraZ); + matrices.pushPose(); + matrices.translate(relativeOrigin.x, relativeOrigin.y, relativeOrigin.z); + Matrix4f mat = matrices.last().pose(); + matrices.popPose(); + + float[] bounds = geometry.contentBounds( + target.u1, + target.v1, + target.u2, + target.v2, + target.fill, + target.scaleX, + target.scaleY, + textureWidth, + textureHeight + ); + int[] triangles = geometry.triangleIndices(); + List vertices = geometry.localVertices(); + List mappedUvs = mappedUvs(target, vertices); + Vector3f normal = geometry.normal(); + VertexConsumer backingConsumer = consumers.getBuffer(ScreenRenderer.getBackingLayer(textureId)); + for (int i = 0; i < triangles.length; i += 3) { + drawBackingTriangle(mat, backingConsumer, target, geometry, vertices, triangles, i, bounds, mappedUvs, normal); + } + + RenderType layer = ScreenRenderer.getLayer(textureId); + VertexConsumer consumer = consumers.getBuffer(layer); + for (int i = 0; i < triangles.length; i += 3) { + drawTextureTriangle(mat, consumer, target, geometry, vertices, triangles, i, bounds, mappedUvs, normal); + } + } + + private static void drawBackingTriangle(Matrix4f mat, VertexConsumer consumer, ClientVideoScreen target, + ScreenGeometry geometry, List vertices, int[] triangles, int offset, + float[] bounds, List mappedUvs, Vector3f normal) { + if (mappedUvs != null) { + drawMappedBackingTriangle(mat, consumer, vertices, triangles, offset, mappedUvs, normal); + return; + } + ArrayList polygon = trianglePolygon(geometry, vertices, triangles, offset); + polygon = clipPolygon(polygon, bounds); + if (polygon.size() < 3) return; + + ProjectedVertex first = polygon.getFirst(); + for (int i = 1; i < polygon.size() - 1; i++) { + drawBackingVertex(mat, consumer, target, geometry, first, bounds, normal); + drawBackingVertex(mat, consumer, target, geometry, polygon.get(i), bounds, normal); + drawBackingVertex(mat, consumer, target, geometry, polygon.get(i + 1), bounds, normal); + drawBackingVertex(mat, consumer, target, geometry, polygon.get(i + 1), bounds, normal); + } + } + + private static void drawBackingVertex(Matrix4f mat, VertexConsumer consumer, ClientVideoScreen target, + ScreenGeometry geometry, ProjectedVertex projected, float[] bounds, Vector3f normal) { + Vector2f uv = geometry.textureCoord(projected.texturePoint.x, projected.texturePoint.y, bounds, target.u1, target.v1, target.u2, target.v2); + drawVertex(mat, consumer, projected.vertex, uv.x, uv.y, BACKING_COLOR, normal); + } + + private static void drawTextureTriangle(Matrix4f mat, VertexConsumer consumer, ClientVideoScreen target, + ScreenGeometry geometry, List vertices, int[] triangles, int offset, + float[] bounds, List mappedUvs, Vector3f normal) { + if (mappedUvs != null) { + drawMappedTextureTriangle(mat, consumer, vertices, triangles, offset, mappedUvs, normal); + return; + } + ArrayList polygon = trianglePolygon(geometry, vertices, triangles, offset); + polygon = clipPolygon(polygon, bounds); + if (polygon.size() < 3) return; + + ProjectedVertex first = polygon.getFirst(); + for (int i = 1; i < polygon.size() - 1; i++) { + drawTextureVertex(mat, consumer, target, geometry, first, bounds, normal); + drawTextureVertex(mat, consumer, target, geometry, polygon.get(i), bounds, normal); + drawTextureVertex(mat, consumer, target, geometry, polygon.get(i + 1), bounds, normal); + drawTextureVertex(mat, consumer, target, geometry, polygon.get(i + 1), bounds, normal); + drawTextureVertex(mat, consumer, target, geometry, first, bounds, normal); + drawTextureVertex(mat, consumer, target, geometry, polygon.get(i + 1), bounds, normal); + drawTextureVertex(mat, consumer, target, geometry, polygon.get(i), bounds, normal); + drawTextureVertex(mat, consumer, target, geometry, polygon.get(i), bounds, normal); + } + } + + private static void drawTextureVertex(Matrix4f mat, VertexConsumer consumer, ClientVideoScreen target, + ScreenGeometry geometry, ProjectedVertex projected, float[] bounds, Vector3f normal) { + Vector2f uv = geometry.textureCoord(projected.texturePoint.x, projected.texturePoint.y, bounds, target.u1, target.v1, target.u2, target.v2); + drawVertex(mat, consumer, projected.vertex, uv.x, uv.y, normal); + } + + private static void drawTriangle(IVideoPlayer player, Matrix4f mat, VertexConsumer consumer, ClientVideoScreen target, + ScreenGeometry geometry, List vertices, int[] triangles, int offset, + float[] bounds, boolean fx, boolean fy, List mappedUvs, Vector3f normal) { + if (mappedUvs != null) { + drawMappedTriangle(player, mat, consumer, target, vertices, triangles, offset, mappedUvs, fx, fy, normal); + return; + } + ArrayList polygon = trianglePolygon(geometry, vertices, triangles, offset); + polygon = clipPolygon(polygon, bounds); + if (polygon.size() < 3) return; + + ProjectedVertex first = polygon.getFirst(); + for (int i = 1; i < polygon.size() - 1; i++) { + drawPlaneVertex(player, mat, consumer, target, geometry, first, bounds, fx, fy, normal); + drawPlaneVertex(player, mat, consumer, target, geometry, polygon.get(i), bounds, fx, fy, normal); + drawPlaneVertex(player, mat, consumer, target, geometry, polygon.get(i + 1), bounds, fx, fy, normal); + drawPlaneVertex(player, mat, consumer, target, geometry, polygon.get(i + 1), bounds, fx, fy, normal); + drawPlaneVertex(player, mat, consumer, target, geometry, first, bounds, fx, fy, normal); + drawPlaneVertex(player, mat, consumer, target, geometry, polygon.get(i + 1), bounds, fx, fy, normal); + drawPlaneVertex(player, mat, consumer, target, geometry, polygon.get(i), bounds, fx, fy, normal); + drawPlaneVertex(player, mat, consumer, target, geometry, polygon.get(i), bounds, fx, fy, normal); + } + } + + private static void drawPlaneVertex(IVideoPlayer player, Matrix4f mat, VertexConsumer consumer, ClientVideoScreen target, + ScreenGeometry geometry, ProjectedVertex projected, float[] bounds, boolean fx, boolean fy, Vector3f normal) { + Vector2f uv = geometry.textureCoord(projected.texturePoint.x, projected.texturePoint.y, bounds, target.u1, target.v1, target.u2, target.v2); + if (fx) uv.x = target.u1 + target.u2 - uv.x; + if (fy) uv.y = target.v1 + target.v2 - uv.y; + player.drawVertex(mat, consumer, projected.vertex, uv, normal, target); + } + + private static void drawMappedTextureTriangle(Matrix4f mat, VertexConsumer consumer, List vertices, + int[] triangles, int offset, List uvs, Vector3f normal) { + drawMappedTextureTriangle(mat, consumer, vertices, triangles, offset, uvs, normal, TRIANGLE_QUAD_ORDER); + drawMappedTextureTriangle(mat, consumer, vertices, triangles, offset, uvs, normal, REVERSED_TRIANGLE_QUAD_ORDER); + } + + private static void drawMappedBackingTriangle(Matrix4f mat, VertexConsumer consumer, List vertices, + int[] triangles, int offset, List uvs, Vector3f normal) { + drawMappedTextureTriangle(mat, consumer, vertices, triangles, offset, uvs, BACKING_COLOR, normal, TRIANGLE_QUAD_ORDER); + } + + private static void drawMappedTriangle(IVideoPlayer player, Matrix4f mat, VertexConsumer consumer, ClientVideoScreen target, + List vertices, int[] triangles, int offset, List uvs, + boolean fx, boolean fy, Vector3f normal) { + drawMappedTriangle(player, mat, consumer, target, vertices, triangles, offset, uvs, fx, fy, normal, TRIANGLE_QUAD_ORDER); + drawMappedTriangle(player, mat, consumer, target, vertices, triangles, offset, uvs, fx, fy, normal, REVERSED_TRIANGLE_QUAD_ORDER); + } + + private static void drawMappedTextureTriangle(Matrix4f mat, VertexConsumer consumer, List vertices, + int[] triangles, int offset, List uvs, Vector3f normal, + int[] order) { + drawMappedTextureTriangle(mat, consumer, vertices, triangles, offset, uvs, color(), normal, order); + } + + private static void drawMappedTextureTriangle(Matrix4f mat, VertexConsumer consumer, List vertices, + int[] triangles, int offset, List uvs, int color, Vector3f normal, + int[] order) { + for (int triangleOffset : order) { + int vertexIndex = triangles[offset + triangleOffset]; + Vector2f uv = uvs.get(vertexIndex); + drawVertex(mat, consumer, vertices.get(vertexIndex), uv.x, uv.y, color, normal); + } + } + + private static void drawMappedTriangle(IVideoPlayer player, Matrix4f mat, VertexConsumer consumer, ClientVideoScreen target, + List vertices, int[] triangles, int offset, List uvs, + boolean fx, boolean fy, Vector3f normal, int[] order) { + for (int triangleOffset : order) { + int vertexIndex = triangles[offset + triangleOffset]; + Vector2f mapped = new Vector2f(uvs.get(vertexIndex)); + if (fx) mapped.x = target.u1 + target.u2 - mapped.x; + if (fy) mapped.y = target.v1 + target.v2 - mapped.y; + player.drawVertex(mat, consumer, vertices.get(vertexIndex), mapped, normal, target); + } + } + + private static List mappedUvs(ClientVideoScreen target, List vertices) { + if (!target.fill) return null; + float[] values = target.metadata.getFloatArray(ScreenMetadata.KEY_MAPPING_UVS); + if (values == null || values.length != vertices.size() * 2) return null; + ArrayList result = new ArrayList<>(vertices.size()); + for (int i = 0; i < vertices.size(); i++) { + result.add(new Vector2f(values[i * 2], values[i * 2 + 1])); + } + return result; + } + + private static ArrayList trianglePolygon(ScreenGeometry geometry, List vertices, int[] triangles, int offset) { + ArrayList polygon = new ArrayList<>(3); + for (int i = 0; i < 3; i++) { + int vertexIndex = triangles[offset + i]; + polygon.add(new ProjectedVertex(geometry.projectedPoint(vertexIndex), geometry.editPoint(vertexIndex), new Vector3f(vertices.get(vertexIndex)))); + } + return polygon; + } + + 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)); + } + } + + static void drawQuad(Matrix4f mat, VertexConsumer consumer, Vector3f p1, Vector3f p2, Vector3f p3, Vector3f p4, float u1, float v1, float u2, float v2) { + Vector3f normal = quadNormal(p1, p2, p3); + drawVertex(mat, consumer, p1, u1, v1, normal); + drawVertex(mat, consumer, p2, u1, v2, normal); + drawVertex(mat, consumer, p3, u2, v2, normal); + drawVertex(mat, consumer, p4, u2, v1, normal); + drawVertex(mat, consumer, p1, u1, v1, normal); + drawVertex(mat, consumer, p4, u2, v1, normal); + drawVertex(mat, consumer, p3, u2, v2, normal); + drawVertex(mat, consumer, p2, u1, v2, normal); + } + + static void drawVertex(Matrix4f mat, VertexConsumer consumer, Vector3f vertex, float u, float v) { + drawVertex(mat, consumer, vertex, u, v, null); + } + + static void drawVertex(Matrix4f mat, VertexConsumer consumer, Vector3f vertex, float u, float v, Vector3f normal) { + drawVertex(mat, consumer, vertex, u, v, color(), normal); + } + + private static void drawVertex(Matrix4f mat, VertexConsumer consumer, Vector3f vertex, float u, float v, int color, Vector3f normal) { + ScreenRenderer.drawWorldTexturedVertex(mat, consumer, vertex, u, v, color, normal); + } + + private static int color() { + int gray = (int) (config.brightness / 100.0 * 255); + return 0xFF000000 | (gray << 16) | (gray << 8) | gray; + } + + private static Vector3f quadNormal(Vector3f p1, Vector3f p2, Vector3f p3) { + Vector3f normal = new Vector3f(p2).sub(p1).cross(new Vector3f(p3).sub(p1)); + if (normal.lengthSquared() < ScreenGeometry.EPSILON * ScreenGeometry.EPSILON) { + return new Vector3f(0, 1, 0); + } + return normal.normalize(); + } +} diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/VideoQuad.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/VideoQuad.java new file mode 100644 index 0000000..3b476c3 --- /dev/null +++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/video/VideoQuad.java @@ -0,0 +1,101 @@ +package com.github.squi2rel.vp.video; + +import com.github.squi2rel.vp.ScreenRenderer; +import org.lwjgl.opengl.GL; +import org.lwjgl.opengl.GLCapabilities; + +import java.nio.ByteBuffer; +import net.minecraft.client.Minecraft; + +import static org.lwjgl.opengl.GL21.*; +import static org.lwjgl.opengl.GL12.GL_BGRA; +import static org.lwjgl.opengl.GL33.GL_TEXTURE_SWIZZLE_A; + +public class VideoQuad { + + private int textureId; + private int width; + private int height; + private boolean textureInitialized = false; + private final PBOManager pbo = new PBOManager(); + + public VideoQuad(int width, int height) { + this.width = width; + this.height = height; + initializeTexture(); + pbo.init(width, height); + } + + public synchronized void resize(int width, int height) { + this.width = width; + this.height = height; + regenTexture(); + pbo.init(width, height); + } + + private void initializeTexture() { + textureId = glGenTextures(); + regenTexture(); + } + + private void regenTexture() { + glBindTexture(GL_TEXTURE_2D, textureId); + + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_MIRRORED_REPEAT); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_MIRRORED_REPEAT); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + forceOpaqueAlpha(); + + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, (ByteBuffer) null); + + glBindTexture(GL_TEXTURE_2D, 0); + textureInitialized = true; + } + + public synchronized void updateTexture(ByteBuffer frameData) { + updateTexture(frameData, GL_RGBA); + } + + public synchronized void updateBgraTexture(ByteBuffer frameData) { + updateTexture(frameData, GL_BGRA); + } + + private void updateTexture(ByteBuffer frameData, int externalFormat) { + if (!pbo.allocated()) return; + glBindTexture(GL_TEXTURE_2D, textureId); + glPixelStorei(GL_UNPACK_ALIGNMENT, 4); + glPixelStorei(GL_UNPACK_ROW_LENGTH, width); + glPixelStorei(GL_UNPACK_SKIP_ROWS, 0); + glPixelStorei(GL_UNPACK_SKIP_PIXELS, 0); + int prevPBO = glGetInteger(GL_PIXEL_UNPACK_BUFFER_BINDING); + try { + pbo.upload(frameData, () -> glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, width, height, externalFormat, GL_UNSIGNED_BYTE, 0)); + } finally { + glBindBuffer(GL_PIXEL_UNPACK_BUFFER, prevPBO); + glBindTexture(GL_TEXTURE_2D, 0); + } + } + + private void forceOpaqueAlpha() { + GLCapabilities capabilities = GL.getCapabilities(); + if (capabilities.OpenGL33 || capabilities.GL_ARB_texture_swizzle) { + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_A, GL_ONE); + } + } + + public void cleanup() { + if (textureInitialized) { + Minecraft.getInstance().execute(() -> { + ScreenRenderer.releaseTexture(textureId); + glDeleteTextures(textureId); + pbo.release(); + }); + textureInitialized = false; + } + } + + public int getTextureId() { + return textureId; + } +} diff --git a/fabric-26.2/src/client/java/com/github/squi2rel/vp/vivecraft/Vivecraft.java b/fabric-26.2/src/client/java/com/github/squi2rel/vp/vivecraft/Vivecraft.java new file mode 100644 index 0000000..4cb3f58 --- /dev/null +++ b/fabric-26.2/src/client/java/com/github/squi2rel/vp/vivecraft/Vivecraft.java @@ -0,0 +1,20 @@ +package com.github.squi2rel.vp.vivecraft; + +import net.fabricmc.loader.api.FabricLoader; +import org.joml.Matrix4f; + +public class Vivecraft { + public static final boolean loaded = FabricLoader.getInstance().isModLoaded("vivecraft"); + + public static boolean isRightEye() { + return VivecraftImpl.isRightEye(); + } + + public static boolean isVRActive() { + return VivecraftImpl.isVRActive(); + } + + public static Matrix4f getRotation() { + return VivecraftImpl.getRotation(); + } +} diff --git a/fabric-26.2/src/client/resources/videoplayer.client.mixins.json b/fabric-26.2/src/client/resources/videoplayer.client.mixins.json new file mode 100644 index 0000000..1ddb169 --- /dev/null +++ b/fabric-26.2/src/client/resources/videoplayer.client.mixins.json @@ -0,0 +1,22 @@ +{ + "required": true, + "package": "com.github.squi2rel.vp.mixin.client", + "compatibilityLevel": "JAVA_21", + "client": [ + "CameraMixin", + "ClientPlayNetworkHandlerMixin", + "DrawContextAccessor", + "GameRendererMixin", + "GameRendererTargetAccessor", + "GlDeviceAccessor", + "GpuDeviceAccessor", + "MinecraftClientMixin", + "VoxyThreadUtilsMixin", + "WindowMixin", + "WorldRendererMixin" + ], + "injectors": { + "defaultRequire": 1 + }, + "mixins": [] +} diff --git a/fabric-26.2/src/main/java/com/github/squi2rel/vp/VideoPlayerMain.java b/fabric-26.2/src/main/java/com/github/squi2rel/vp/VideoPlayerMain.java new file mode 100644 index 0000000..da3a1aa --- /dev/null +++ b/fabric-26.2/src/main/java/com/github/squi2rel/vp/VideoPlayerMain.java @@ -0,0 +1,91 @@ +package com.github.squi2rel.vp; + +import com.github.squi2rel.vp.network.ServerPacketHandler; +import com.github.squi2rel.vp.network.VideoPackets; +import com.github.squi2rel.vp.network.VideoPayload; +import com.github.squi2rel.vp.provider.VideoProviders; +import com.mojang.brigadier.arguments.StringArgumentType; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import net.fabricmc.api.ModInitializer; + +import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLevelEvents; +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.commands.Commands; +import net.minecraft.network.chat.Component; +import net.minecraft.server.MinecraftServer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; + +public class VideoPlayerMain implements ModInitializer { + public static final String MOD_ID = "videoplayer"; + public static final String version = FabricLoader.getInstance().getModContainer(MOD_ID).orElseThrow().getMetadata().getVersion().toString(); + public static Throwable error = null; + public static MinecraftServer server; + + public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID); + public static final boolean android = Files.exists(Path.of("/system/build.prop")); + + public static ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1, VideoPlayerMain::newDaemon); + + @SuppressWarnings("resource") + @Override + public void onInitialize() { + if (android) { + LOGGER.info("Android device detected; native libraries will use the Android ABI package"); + } + System.setProperty("videoplayer.version", version); + VideoProviders.register(); + VideoPayload.register(); + ServerLifecycleEvents.SERVER_STARTED.register(s -> { + server = s; + DataHolder.load(s); + }); + ServerLifecycleEvents.SERVER_STOPPING.register(DataHolder::stop); + ServerLifecycleEvents.BEFORE_SAVE.register((s, flush, force) -> DataHolder.save()); + ServerLevelEvents.LOAD.register(DataHolder::loadWorld); + ServerLevelEvents.UNLOAD.register(DataHolder::unloadWorld); + ServerTickEvents.START_SERVER_TICK.register(ignored -> DataHolder.update()); + ServerPlayConnectionEvents.JOIN.register((e, p, s) -> DataHolder.playerJoin(e.player)); + ServerPlayConnectionEvents.DISCONNECT.register((e, s) -> DataHolder.playerLeave(e.player.getUUID())); + ServerPlayNetworking.registerGlobalReceiver(VideoPayload.ID, (p, c) -> { + long receivedAt = System.currentTimeMillis(); + byte[] copy = p.data().clone(); + if (copy.length > VideoPackets.MAX_PAYLOAD_BYTES) { + c.player().connection.disconnect(Component.nullToEmpty("VideoPlayer payload is too large")); + return; + } + c.server().execute(() -> { + ByteBuf buf = Unpooled.wrappedBuffer(copy); + try { + ServerPacketHandler.handle(c.player(), buf, receivedAt); + } catch (Exception e) { + c.player().connection.disconnect(Component.nullToEmpty(e.toString())); + } finally { + buf.release(); + } + }); + }); + CommandRegistrationCallback.EVENT.register((d, c, e) -> d.register(Commands.literal("").then(Commands.argument("command", StringArgumentType.greedyString()).executes(s -> { + if (!s.getSource().isPlayer()) return 0; + ServerPacketHandler.sendTo(s.getSource().getPlayer(), VideoPackets.execute(s.getArgument("command", String.class))); + return 1; + })))); + } + + private static Thread newDaemon(Runnable task) { + Thread t = new Thread(task); + t.setDaemon(true); + return t; + } +} diff --git a/fabric-26.2/src/main/java/com/github/squi2rel/vp/creation/TextInputFilter.java b/fabric-26.2/src/main/java/com/github/squi2rel/vp/creation/TextInputFilter.java new file mode 100644 index 0000000..ee6f234 --- /dev/null +++ b/fabric-26.2/src/main/java/com/github/squi2rel/vp/creation/TextInputFilter.java @@ -0,0 +1,33 @@ +package com.github.squi2rel.vp.creation; + +import java.util.Objects; +import java.util.function.Predicate; + +public final class TextInputFilter { + private TextInputFilter() { + } + + public static boolean accepts(Predicate filter, String value, int cursor, String highlighted, String insertion) { + Objects.requireNonNull(filter, "filter"); + String current = value == null ? "" : value; + String selected = highlighted == null ? "" : highlighted; + String inserted = insertion == null ? "" : insertion; + int safeCursor = Math.clamp(cursor, 0, current.length()); + int start = safeCursor; + int end = safeCursor; + + if (!selected.isEmpty()) { + if (safeCursor + selected.length() <= current.length() + && current.regionMatches(safeCursor, selected, 0, selected.length())) { + end = safeCursor + selected.length(); + } else if (safeCursor - selected.length() >= 0 + && current.regionMatches(safeCursor - selected.length(), selected, 0, selected.length())) { + start = safeCursor - selected.length(); + } else { + return false; + } + } + + return filter.test(current.substring(0, start) + inserted + current.substring(end)); + } +} diff --git a/fabric-26.2/src/main/java/com/github/squi2rel/vp/network/ClientMessageBridge.java b/fabric-26.2/src/main/java/com/github/squi2rel/vp/network/ClientMessageBridge.java new file mode 100644 index 0000000..6701282 --- /dev/null +++ b/fabric-26.2/src/main/java/com/github/squi2rel/vp/network/ClientMessageBridge.java @@ -0,0 +1,13 @@ +package com.github.squi2rel.vp.network; + +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; + +public final class ClientMessageBridge { + private ClientMessageBridge() { + } + + public static void sendOverlay(ServerPlayer player, Component message) { + player.sendOverlayMessage(message); + } +} diff --git a/fabric-26.2/src/main/java/com/github/squi2rel/vp/network/VideoPayload.java b/fabric-26.2/src/main/java/com/github/squi2rel/vp/network/VideoPayload.java new file mode 100644 index 0000000..efe3778 --- /dev/null +++ b/fabric-26.2/src/main/java/com/github/squi2rel/vp/network/VideoPayload.java @@ -0,0 +1,31 @@ +package com.github.squi2rel.vp.network; + +import com.github.squi2rel.vp.VideoPlayerMain; +import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; + +public record VideoPayload(byte[] data) implements CustomPacketPayload { + public static final Identifier VIDEO_PAYLOAD_ID = Identifier.fromNamespaceAndPath(VideoPlayerMain.MOD_ID, "video"); + public static final CustomPacketPayload.Type ID = new CustomPacketPayload.Type<>(VIDEO_PAYLOAD_ID); + public static final StreamCodec CODEC = StreamCodec.ofMember((p, buf) -> buf.writeBytes(p.data), buf -> { + if (buf.readableBytes() > VideoPackets.MAX_PAYLOAD_BYTES) { + throw new IllegalStateException("VideoPlayer payload exceeds " + VideoPackets.MAX_PAYLOAD_BYTES + " bytes"); + } + byte[] data = new byte[buf.readableBytes()]; + buf.readBytes(data); + return new VideoPayload(data); + }); + + @Override + public Type type() { + return ID; + } + + public static void register() { + PayloadTypeRegistry.clientboundPlay().register(ID, CODEC); + PayloadTypeRegistry.serverboundPlay().register(ID, CODEC); + } +} diff --git a/fabric-26.2/src/main/java/com/github/squi2rel/vp/render/CameraRenderGuard.java b/fabric-26.2/src/main/java/com/github/squi2rel/vp/render/CameraRenderGuard.java new file mode 100644 index 0000000..7f4e88c --- /dev/null +++ b/fabric-26.2/src/main/java/com/github/squi2rel/vp/render/CameraRenderGuard.java @@ -0,0 +1,34 @@ +package com.github.squi2rel.vp.render; + +import java.util.concurrent.atomic.AtomicBoolean; + +public final class CameraRenderGuard { + private final AtomicBoolean rendering = new AtomicBoolean(); + + public Scope enter() { + return rendering.compareAndSet(false, true) ? new Scope(this) : null; + } + + public boolean isRendering() { + return rendering.get(); + } + + private void exit() { + rendering.set(false); + } + + public static final class Scope implements AutoCloseable { + private CameraRenderGuard owner; + + private Scope(CameraRenderGuard owner) { + this.owner = owner; + } + + @Override + public void close() { + CameraRenderGuard current = owner; + owner = null; + if (current != null) current.exit(); + } + } +} diff --git a/fabric-26.2/src/main/java/com/github/squi2rel/vp/render/FrameRenderGeometry.java b/fabric-26.2/src/main/java/com/github/squi2rel/vp/render/FrameRenderGeometry.java new file mode 100644 index 0000000..ba18778 --- /dev/null +++ b/fabric-26.2/src/main/java/com/github/squi2rel/vp/render/FrameRenderGeometry.java @@ -0,0 +1,107 @@ +package com.github.squi2rel.vp.render; + +import java.util.Objects; + +public final class FrameRenderGeometry { + public static final int UV = 1; + public static final int NORMAL = 1 << 1; + public static final int LIGHT = 1 << 2; + public static final int OVERLAY = 1 << 3; + public static final int LINE_WIDTH = 1 << 4; + + private final float[] positions; + private final float[] uvs; + private final float[] normals; + private final float[] lineWidths; + private final int[] colors; + private final int[] lights; + private final int[] overlays; + private final int[] attributes; + + public FrameRenderGeometry(float[] positions, float[] uvs, float[] normals, float[] lineWidths, + int[] colors, int[] lights, int[] overlays, int[] attributes) { + Objects.requireNonNull(positions, "positions"); + Objects.requireNonNull(uvs, "uvs"); + Objects.requireNonNull(normals, "normals"); + Objects.requireNonNull(lineWidths, "lineWidths"); + Objects.requireNonNull(colors, "colors"); + Objects.requireNonNull(lights, "lights"); + Objects.requireNonNull(overlays, "overlays"); + Objects.requireNonNull(attributes, "attributes"); + if (positions.length % 3 != 0) throw new IllegalArgumentException("positions"); + int count = positions.length / 3; + if (uvs.length != count * 2 + || normals.length != count * 3 + || lineWidths.length != count + || colors.length != count + || lights.length != count + || overlays.length != count + || attributes.length != count) { + throw new IllegalArgumentException("vertex attributes"); + } + this.positions = positions.clone(); + this.uvs = uvs.clone(); + this.normals = normals.clone(); + this.lineWidths = lineWidths.clone(); + this.colors = colors.clone(); + this.lights = lights.clone(); + this.overlays = overlays.clone(); + this.attributes = attributes.clone(); + } + + public int vertexCount() { + return colors.length; + } + + public float x(int vertex) { + return positions[vertex * 3]; + } + + public float y(int vertex) { + return positions[vertex * 3 + 1]; + } + + public float z(int vertex) { + return positions[vertex * 3 + 2]; + } + + public float u(int vertex) { + return uvs[vertex * 2]; + } + + public float v(int vertex) { + return uvs[vertex * 2 + 1]; + } + + public float normalX(int vertex) { + return normals[vertex * 3]; + } + + public float normalY(int vertex) { + return normals[vertex * 3 + 1]; + } + + public float normalZ(int vertex) { + return normals[vertex * 3 + 2]; + } + + public float lineWidth(int vertex) { + return lineWidths[vertex]; + } + + public int color(int vertex) { + return colors[vertex]; + } + + public int light(int vertex) { + return lights[vertex]; + } + + public int overlay(int vertex) { + return overlays[vertex]; + } + + public boolean has(int vertex, int attribute) { + return (attributes[vertex] & attribute) != 0; + } +} diff --git a/fabric-26.2/src/test/java/com/github/squi2rel/vp/creation/TextInputFilterTest.java b/fabric-26.2/src/test/java/com/github/squi2rel/vp/creation/TextInputFilterTest.java new file mode 100644 index 0000000..f003a18 --- /dev/null +++ b/fabric-26.2/src/test/java/com/github/squi2rel/vp/creation/TextInputFilterTest.java @@ -0,0 +1,41 @@ +package com.github.squi2rel.vp.creation; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class TextInputFilterTest { + @Test + void acceptsInsertionWhenCandidateMatchesFilter() { + assertTrue(TextInputFilter.accepts( + value -> value.chars().allMatch(Character::isDigit), + "12", + 2, + "", + "3" + )); + } + + @Test + void rejectsInsertionWhenCandidateDoesNotMatchFilter() { + assertFalse(TextInputFilter.accepts( + value -> value.chars().allMatch(Character::isDigit), + "12", + 2, + "", + "a" + )); + } + + @Test + void validatesCandidateAfterReplacingSelection() { + assertTrue(TextInputFilter.accepts( + value -> value.equals("a9d"), + "abcd", + 1, + "bc", + "9" + )); + } +} diff --git a/fabric-26.2/src/test/java/com/github/squi2rel/vp/render/CameraRenderGuardTest.java b/fabric-26.2/src/test/java/com/github/squi2rel/vp/render/CameraRenderGuardTest.java new file mode 100644 index 0000000..ae57e74 --- /dev/null +++ b/fabric-26.2/src/test/java/com/github/squi2rel/vp/render/CameraRenderGuardTest.java @@ -0,0 +1,25 @@ +package com.github.squi2rel.vp.render; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class CameraRenderGuardTest { + @Test + void rejectsRecursionAndRestoresStateAfterFailure() { + CameraRenderGuard guard = new CameraRenderGuard(); + + try { + try (CameraRenderGuard.Scope ignored = guard.enter()) { + assertTrue(guard.isRendering()); + assertNull(guard.enter()); + throw new IllegalStateException("render failed"); + } + } catch (IllegalStateException ignored) { + } + + assertFalse(guard.isRendering()); + } +} diff --git a/fabric-26.2/src/test/java/com/github/squi2rel/vp/render/FrameRenderGeometryTest.java b/fabric-26.2/src/test/java/com/github/squi2rel/vp/render/FrameRenderGeometryTest.java new file mode 100644 index 0000000..14f1c57 --- /dev/null +++ b/fabric-26.2/src/test/java/com/github/squi2rel/vp/render/FrameRenderGeometryTest.java @@ -0,0 +1,35 @@ +package com.github.squi2rel.vp.render; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class FrameRenderGeometryTest { + @Test + void copiesMutableVertexArraysAtSnapshotBoundary() { + float[] positions = {1.0f, 2.0f, 3.0f}; + float[] uvs = {0.25f, 0.75f}; + float[] normals = {0.0f, 1.0f, 0.0f}; + float[] lineWidths = {1.0f}; + int[] colors = {0xFF112233}; + int[] lights = {0x00F000F0}; + int[] overlays = {0}; + int[] attributes = {FrameRenderGeometry.UV | FrameRenderGeometry.NORMAL}; + + FrameRenderGeometry snapshot = new FrameRenderGeometry( + positions, + uvs, + normals, + lineWidths, + colors, + lights, + overlays, + attributes + ); + positions[0] = 99.0f; + colors[0] = 0; + + assertEquals(1.0f, snapshot.x(0)); + assertEquals(0xFF112233, snapshot.color(0)); + } +} diff --git a/gradle.properties b/gradle.properties index f870ac5..a9cf78e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,10 +7,10 @@ org.gradle.parallel=true minecraft_version=1.21.11 yarn_mappings=1.21.11+build.6 loader_version=0.17.3 -loom_version=1.17.12 +loom_version=1.17.17 # Mod Properties -mod_version=2.0.2 +mod_version=2.0.3 maven_group=com.github.squi2rel.vp archives_base_name=VideoPlayer @@ -18,3 +18,10 @@ archives_base_name=VideoPlayer fabric_version=0.141.4+1.21.11 modmenu_version=17.0.0 paper_api_version=1.21.11-R0.1-SNAPSHOT +paper_api_26_2_version=26.2.build.92-stable +minecraft_26_2_version=26.2 +loader_26_2_version=0.19.3 +fabric_26_2_version=0.156.0+26.2 +modmenu_26_2_version=20.0.1 +vivecraft_26_2_version=26.2-1.3.15-fabric +mockito_version=5.23.0 diff --git a/mcng-core/build.gradle b/mcng-core/build.gradle index b92c3e1..cd00ed4 100644 --- a/mcng-core/build.gradle +++ b/mcng-core/build.gradle @@ -20,6 +20,9 @@ tasks.withType(JavaCompile).configureEach { } java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } diff --git a/mcng-fabric-client-26.2/build.gradle b/mcng-fabric-client-26.2/build.gradle new file mode 100644 index 0000000..f1fc107 --- /dev/null +++ b/mcng-fabric-client-26.2/build.gradle @@ -0,0 +1,42 @@ +plugins { + id 'net.fabricmc.fabric-loom' + id 'maven-publish' +} + +base { + archivesName = "${rootProject.archives_base_name}-fabric-client-26.2" +} + +dependencies { + minecraft "com.mojang:minecraft:${rootProject.minecraft_26_2_version}" + implementation "net.fabricmc:fabric-loader:${rootProject.loader_26_2_version}" + implementation project(':mcng-core') + testImplementation platform('org.junit:junit-bom:5.11.4') + testImplementation 'org.junit.jupiter:junit-jupiter' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +test { + useJUnitPlatform() +} + +tasks.withType(JavaCompile).configureEach { + options.release = 25 +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(25) + } + sourceCompatibility = JavaVersion.VERSION_25 + targetCompatibility = JavaVersion.VERSION_25 +} + +publishing { + publications { + create('mavenJava', MavenPublication) { + from components.java + artifactId = "${rootProject.archives_base_name}-fabric-client-26.2" + } + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/BuiltinDebugNodeComponentRegistrar.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/BuiltinDebugNodeComponentRegistrar.java new file mode 100644 index 0000000..4f55b0c --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/BuiltinDebugNodeComponentRegistrar.java @@ -0,0 +1,12 @@ +package com.github.squi2rel.mcng.fabric.client; + +import com.github.squi2rel.mcng.core.builtin.BuiltinNodeTypes; + +public final class BuiltinDebugNodeComponentRegistrar { + private BuiltinDebugNodeComponentRegistrar() { + } + + public static void registerDebugComponents(NodeComponentRegistry registry) { + registry.register(new NodeComponentDefinition(BuiltinNodeTypes.IMAGE_PREVIEW.id(), ImagePreviewNodeBodyComponent::new, ResizePolicy.allSides())); + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/BuiltinNodePaletteRegistrar.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/BuiltinNodePaletteRegistrar.java new file mode 100644 index 0000000..8082c49 --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/BuiltinNodePaletteRegistrar.java @@ -0,0 +1,71 @@ +package com.github.squi2rel.mcng.fabric.client; + +import com.github.squi2rel.mcng.core.builtin.BuiltinNodeTypes; + +import java.util.List; + +public final class BuiltinNodePaletteRegistrar { + private static final int CONSTANTS_ORDER = 100; + private static final int OPERATIONS_ORDER = 200; + private static final int EVENTS_ORDER = 300; + private static final int DEBUG_ORDER = 400; + private static final String CONSTANTS_KEY = "mcng.ui.palette.section.constants"; + private static final String OPERATIONS_KEY = "mcng.ui.palette.section.operations"; + private static final String CONTROL_KEY = "mcng.ui.palette.section.control"; + private static final String VARIABLES_KEY = "mcng.ui.palette.section.variables"; + private static final String EVENTS_KEY = "mcng.ui.palette.section.events"; + private static final String DEBUG_KEY = "mcng.ui.palette.section.debug"; + + private BuiltinNodePaletteRegistrar() { + } + + public static void registerCoreEntries(NodePaletteRegistry registry) { + registry.register(new NodePaletteDefinition(BuiltinNodeTypes.NUMERIC_CONSTANT.id(), "Constants", CONSTANTS_KEY, CONSTANTS_ORDER, 10, List.of("number", "constant"))); + registry.register(new NodePaletteDefinition(BuiltinNodeTypes.BOOLEAN_CONSTANT.id(), "Constants", CONSTANTS_KEY, CONSTANTS_ORDER, 20, List.of("bool", "constant"))); + registry.register(new NodePaletteDefinition(BuiltinNodeTypes.STRING_CONSTANT.id(), "Constants", CONSTANTS_KEY, CONSTANTS_ORDER, 30, List.of("text", "constant"))); + registry.register(new NodePaletteDefinition(BuiltinNodeTypes.TYPE_CONSTANT.id(), "Constants", CONSTANTS_KEY, CONSTANTS_ORDER, 40, List.of("type", "class", "constant"))); + registry.register(new NodePaletteDefinition(BuiltinNodeTypes.ADD.id(), "Operations", OPERATIONS_KEY, OPERATIONS_ORDER, 10, List.of("math", "sum"))); + registry.register(new NodePaletteDefinition(BuiltinNodeTypes.SUBTRACT.id(), "Operations", OPERATIONS_KEY, OPERATIONS_ORDER, 20, List.of("math", "minus"))); + registry.register(new NodePaletteDefinition(BuiltinNodeTypes.MULTIPLY.id(), "Operations", OPERATIONS_KEY, OPERATIONS_ORDER, 30, List.of("math", "product"))); + registry.register(new NodePaletteDefinition(BuiltinNodeTypes.CAST.id(), "Operations", OPERATIONS_KEY, OPERATIONS_ORDER, 40, List.of("convert", "type"))); + registry.register(new NodePaletteDefinition(BuiltinNodeTypes.ROUND.id(), "Operations", OPERATIONS_KEY, OPERATIONS_ORDER, 50, List.of("math", "integer"))); + registry.register(new NodePaletteDefinition(BuiltinNodeTypes.CONCAT.id(), "Operations", OPERATIONS_KEY, OPERATIONS_ORDER, 60, List.of("string", "text"))); + registry.register(new NodePaletteDefinition(BuiltinNodeTypes.LESS_THAN.id(), "Operations", OPERATIONS_KEY, OPERATIONS_ORDER, 70, List.of("compare", "boolean"))); + registry.register(new NodePaletteDefinition(BuiltinNodeTypes.EQUALS.id(), "Operations", OPERATIONS_KEY, OPERATIONS_ORDER, 80, List.of("compare", "equal"))); + registry.register(new NodePaletteDefinition(BuiltinNodeTypes.SELECT.id(), "Operations", OPERATIONS_KEY, OPERATIONS_ORDER, 90, List.of("branch", "choose", "ternary"))); + registry.register(new NodePaletteDefinition(BuiltinNodeTypes.AND.id(), "Operations", OPERATIONS_KEY, OPERATIONS_ORDER, 100, List.of("boolean", "logic"))); + registry.register(new NodePaletteDefinition(BuiltinNodeTypes.OR.id(), "Operations", OPERATIONS_KEY, OPERATIONS_ORDER, 110, List.of("boolean", "logic"))); + registry.register(new NodePaletteDefinition(BuiltinNodeTypes.IDENTITY.id(), "Operations", OPERATIONS_KEY, OPERATIONS_ORDER, 120, List.of("generic", "pass through"))); + registry.register(new NodePaletteDefinition(BuiltinNodeTypes.LIST_CREATE.id(), "Operations", OPERATIONS_KEY, OPERATIONS_ORDER, 130, List.of("list", "array", "collection"))); + registry.register(new NodePaletteDefinition(BuiltinNodeTypes.LIST_APPEND.id(), "Operations", OPERATIONS_KEY, OPERATIONS_ORDER, 140, List.of("list", "append", "push"))); + registry.register(new NodePaletteDefinition(BuiltinNodeTypes.LIST_GET.id(), "Operations", OPERATIONS_KEY, OPERATIONS_ORDER, 150, List.of("list", "index", "array"))); + registry.register(new NodePaletteDefinition(BuiltinNodeTypes.MAP_PUT.id(), "Operations", OPERATIONS_KEY, OPERATIONS_ORDER, 160, List.of("map", "dict", "set"))); + registry.register(new NodePaletteDefinition(BuiltinNodeTypes.MAP_GET.id(), "Operations", OPERATIONS_KEY, OPERATIONS_ORDER, 170, List.of("map", "dict", "get"))); + registry.register(new NodePaletteDefinition(BuiltinNodeTypes.MAP_KEYS.id(), "Operations", OPERATIONS_KEY, OPERATIONS_ORDER, 180, List.of("map", "dict", "keys"))); + registry.register(new NodePaletteDefinition(BuiltinNodeTypes.TYPE_OF.id(), "Operations", OPERATIONS_KEY, OPERATIONS_ORDER, 190, List.of("type", "class", "inspect"))); + registry.register(new NodePaletteDefinition(BuiltinNodeTypes.CONVERT_TYPE.id(), "Operations", OPERATIONS_KEY, OPERATIONS_ORDER, 200, List.of("convert", "cast", "type"))); + registry.register(new NodePaletteDefinition(BuiltinNodeTypes.REROUTE.id(), "Operations", OPERATIONS_KEY, OPERATIONS_ORDER, 210, List.of("wire", "route"))); + registry.register(new NodePaletteDefinition(BuiltinNodeTypes.NOT.id(), "Operations", OPERATIONS_KEY, OPERATIONS_ORDER, 220, List.of("boolean", "logic"))); + registry.register(new NodePaletteDefinition(BuiltinNodeTypes.IF.id(), "Control", CONTROL_KEY, OPERATIONS_ORDER + 10, 10, List.of("branch", "condition"))); + registry.register(new NodePaletteDefinition(BuiltinNodeTypes.WHILE.id(), "Control", CONTROL_KEY, OPERATIONS_ORDER + 10, 20, List.of("loop", "iterate"))); + registry.register(new NodePaletteDefinition(BuiltinNodeTypes.MERGE.id(), "Control", CONTROL_KEY, OPERATIONS_ORDER + 10, 30, List.of("join", "merge", "union"))); + registry.register(new NodePaletteDefinition(BuiltinNodeTypes.GATE.id(), "Control", CONTROL_KEY, OPERATIONS_ORDER + 10, 40, List.of("gate", "open", "close", "switch"))); + registry.register(new NodePaletteDefinition(BuiltinNodeTypes.SEQUENCE.id(), "Control", CONTROL_KEY, OPERATIONS_ORDER + 10, 50, List.of("sequence", "step", "fanout"))); + registry.register(new NodePaletteDefinition(BuiltinNodeTypes.ONCE.id(), "Control", CONTROL_KEY, OPERATIONS_ORDER + 10, 60, List.of("once", "single", "do once"))); + registry.register(new NodePaletteDefinition(BuiltinNodeTypes.FLIP_FLOP.id(), "Control", CONTROL_KEY, OPERATIONS_ORDER + 10, 70, List.of("flipflop", "toggle", "alternate"))); + registry.register(new NodePaletteDefinition(BuiltinNodeTypes.GET_VARIABLE.id(), "Variables", VARIABLES_KEY, OPERATIONS_ORDER + 20, 10, List.of("state", "read"))); + registry.register(new NodePaletteDefinition(BuiltinNodeTypes.SET_VARIABLE.id(), "Variables", VARIABLES_KEY, OPERATIONS_ORDER + 20, 20, List.of("state", "write"))); + registry.register(new NodePaletteDefinition(BuiltinNodeTypes.LATCH.id(), "Variables", VARIABLES_KEY, OPERATIONS_ORDER + 20, 30, List.of("state", "memory", "local"))); + } + + public static void registerDebugEntries(NodePaletteRegistry registry) { + registry.register(new NodePaletteDefinition(BuiltinNodeTypes.MANUAL_TRIGGER.id(), "Events", EVENTS_KEY, EVENTS_ORDER, 10, List.of("event", "trigger"))); + registry.register(new NodePaletteDefinition(BuiltinNodeTypes.DEBUG_OUTPUT.id(), "Debug", DEBUG_KEY, DEBUG_ORDER, 10, List.of("log", "print", "debug"))); + registry.register(new NodePaletteDefinition(BuiltinNodeTypes.IMAGE_PREVIEW.id(), "Debug", DEBUG_KEY, DEBUG_ORDER, 20, List.of("image", "preview", "demo"))); + } + + public static void registerAll(NodePaletteRegistry registry) { + registerCoreEntries(registry); + registerDebugEntries(registry); + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/EdgeRenderer.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/EdgeRenderer.java new file mode 100644 index 0000000..39e3625 --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/EdgeRenderer.java @@ -0,0 +1,262 @@ +package com.github.squi2rel.mcng.fabric.client; + +import java.util.ArrayList; +import java.util.List; +import net.minecraft.client.gui.GuiGraphicsExtractor; + +public final class EdgeRenderer { + private static final int MIN_CONTROL_OFFSET = 28; + private static final int MAX_CONTROL_OFFSET = 96; + private static final int MIN_SEGMENTS = 16; + private static final int MAX_SEGMENTS = 42; + private static final int LINE_RADIUS = 1; + + private EdgeRenderer() { + } + + public static void drawEdge( + GuiGraphicsExtractor context, + int startX, + int startY, + int endX, + int endY, + int startColor, + int endColor, + EdgeStyle edgeStyle, + NodeWidget.PortSide startSide, + NodeWidget.PortSide endSide + ) { + switch (edgeStyle) { + case STRAIGHT -> drawStraight(context, startX, startY, endX, endY, startColor, endColor); + case ORTHOGONAL -> drawOrthogonal(context, startX, startY, endX, endY, startColor, endColor, startSide, endSide); + case CURVE -> drawCurve(context, startX, startY, endX, endY, startColor, endColor, startSide, endSide); + } + } + + private static void drawStraight(GuiGraphicsExtractor context, int startX, int startY, int endX, int endY, int startColor, int endColor) { + drawGradientSegment(context, startX, startY, endX, endY, startColor, endColor, 0.0, 1.0); + } + + private static void drawOrthogonal( + GuiGraphicsExtractor context, + int startX, + int startY, + int endX, + int endY, + int startColor, + int endColor, + NodeWidget.PortSide startSide, + NodeWidget.PortSide endSide + ) { + double distance = Math.hypot(endX - startX, endY - startY); + double offset = clamp(distance * 0.25, 14.0, 48.0); + Point start = new Point(startX, startY); + Point startExit = offsetPoint(startX, startY, startSide, offset); + Point endEntry = offsetPoint(endX, endY, endSide, offset); + List points = new ArrayList<>(); + appendPoint(points, start); + appendPoint(points, startExit); + if (startSide.isHorizontal() && endSide.isHorizontal()) { + int midX = startExit.x() + ((endEntry.x() - startExit.x()) / 2); + appendPoint(points, new Point(midX, startExit.y())); + appendPoint(points, new Point(midX, endEntry.y())); + } else if (startSide.isVertical() && endSide.isVertical()) { + int midY = startExit.y() + ((endEntry.y() - startExit.y()) / 2); + appendPoint(points, new Point(startExit.x(), midY)); + appendPoint(points, new Point(endEntry.x(), midY)); + } else { + appendPoint(points, new Point(endEntry.x(), startExit.y())); + } + appendPoint(points, endEntry); + appendPoint(points, new Point(endX, endY)); + drawGradientOrthogonalPolyline(context, points, startColor, endColor); + } + + private static void drawCurve( + GuiGraphicsExtractor context, + int startX, + int startY, + int endX, + int endY, + int startColor, + int endColor, + NodeWidget.PortSide startSide, + NodeWidget.PortSide endSide + ) { + double dx = endX - startX; + double distance = Math.hypot(dx, endY - startY); + CurveControls controls = curveControls(startX, startY, endX, endY, startSide, endSide); + int segments = (int) clamp(Math.round(distance / 14.0), MIN_SEGMENTS, MAX_SEGMENTS); + List points = new ArrayList<>(); + points.add(new Point(startX, startY)); + for (int index = 1; index <= segments; index++) { + double t = index / (double) segments; + int x = (int) Math.round(cubic(startX, controls.control1X(), controls.control2X(), endX, t)); + int y = (int) Math.round(cubic(startY, controls.control1Y(), controls.control2Y(), endY, t)); + points.add(new Point(x, y)); + } + drawGradientPolyline(context, points, startColor, endColor); + } + + static CurveControls curveControls(int startX, int startY, int endX, int endY, NodeWidget.PortSide startSide, NodeWidget.PortSide endSide) { + double distance = Math.hypot(endX - startX, endY - startY); + double controlOffset = clamp(distance * 0.35, MIN_CONTROL_OFFSET, MAX_CONTROL_OFFSET); + if (startSide == endSide) { + controlOffset = Math.min(MAX_CONTROL_OFFSET, Math.max(controlOffset, MIN_CONTROL_OFFSET * 2.0)); + } + return new CurveControls( + startX + (startSide.normalX() * controlOffset), + startY + (startSide.normalY() * controlOffset), + endX + (endSide.normalX() * controlOffset), + endY + (endSide.normalY() * controlOffset) + ); + } + + private static void drawGradientPolyline(GuiGraphicsExtractor context, List points, int startColor, int endColor) { + double totalLength = 0.0; + for (int index = 1; index < points.size(); index++) { + totalLength += distance(points.get(index - 1), points.get(index)); + } + if (totalLength <= 0.0) { + return; + } + + double traversed = 0.0; + for (int index = 1; index < points.size(); index++) { + Point from = points.get(index - 1); + Point to = points.get(index); + double segmentLength = distance(from, to); + double startT = traversed / totalLength; + double endT = (traversed + segmentLength) / totalLength; + drawGradientSegment(context, from.x(), from.y(), to.x(), to.y(), startColor, endColor, startT, endT); + traversed += segmentLength; + } + } + + private static void drawGradientOrthogonalPolyline(GuiGraphicsExtractor context, List points, int startColor, int endColor) { + double totalLength = 0.0; + for (int index = 1; index < points.size(); index++) { + totalLength += distance(points.get(index - 1), points.get(index)); + } + if (totalLength <= 0.0) { + return; + } + + double traversed = 0.0; + for (int index = 1; index < points.size(); index++) { + Point from = points.get(index - 1); + Point to = points.get(index); + double segmentLength = distance(from, to); + if (segmentLength <= 0.0) { + continue; + } + double startT = traversed / totalLength; + double endT = (traversed + segmentLength) / totalLength; + drawGradientOrthogonalSegment(context, from, to, startColor, endColor, startT, endT); + traversed += segmentLength; + } + } + + private static void drawGradientSegment( + GuiGraphicsExtractor context, + double startX, + double startY, + double endX, + double endY, + int startColor, + int endColor, + double startT, + double endT + ) { + int steps = Math.max(1, (int) Math.ceil(Math.max(Math.abs(endX - startX), Math.abs(endY - startY)))); + for (int step = 0; step <= steps; step++) { + double segmentT = step / (double) steps; + double globalT = startT + ((endT - startT) * segmentT); + int x = (int) Math.round(startX + ((endX - startX) * segmentT)); + int y = (int) Math.round(startY + ((endY - startY) * segmentT)); + int color = mixColor(startColor, endColor, globalT); + context.fill(x - LINE_RADIUS, y - LINE_RADIUS, x + LINE_RADIUS + 1, y + LINE_RADIUS + 1, color); + } + } + + private static void drawGradientOrthogonalSegment( + GuiGraphicsExtractor context, + Point from, + Point to, + int startColor, + int endColor, + double startT, + double endT + ) { + int dx = to.x() - from.x(); + int dy = to.y() - from.y(); + if (dx != 0 && dy != 0) { + drawGradientSegment(context, from.x(), from.y(), to.x(), to.y(), startColor, endColor, startT, endT); + return; + } + + int steps = Math.abs(dx) + Math.abs(dy); + int stepX = Integer.compare(dx, 0); + int stepY = Integer.compare(dy, 0); + for (int step = 0; step <= steps; step++) { + double segmentT = steps == 0 ? 0.0 : step / (double) steps; + double globalT = startT + ((endT - startT) * segmentT); + int color = mixColor(startColor, endColor, globalT); + int x = from.x() + (stepX * step); + int y = from.y() + (stepY * step); + if (stepX != 0) { + context.fill(x, y - LINE_RADIUS, x + 1, y + LINE_RADIUS + 1, color); + } else { + context.fill(x - LINE_RADIUS, y, x + LINE_RADIUS + 1, y + 1, color); + } + } + } + + private static double cubic(double p0, double p1, double p2, double p3, double t) { + double inverse = 1.0 - t; + return (inverse * inverse * inverse * p0) + + (3.0 * inverse * inverse * t * p1) + + (3.0 * inverse * t * t * p2) + + (t * t * t * p3); + } + + private static double distance(Point from, Point to) { + return Math.hypot(to.x() - from.x(), to.y() - from.y()); + } + + private static int mixColor(int startColor, int endColor, double t) { + int alpha = mixChannel((startColor >>> 24) & 0xFF, (endColor >>> 24) & 0xFF, t); + int red = mixChannel((startColor >>> 16) & 0xFF, (endColor >>> 16) & 0xFF, t); + int green = mixChannel((startColor >>> 8) & 0xFF, (endColor >>> 8) & 0xFF, t); + int blue = mixChannel(startColor & 0xFF, endColor & 0xFF, t); + return (alpha << 24) | (red << 16) | (green << 8) | blue; + } + + private static int mixChannel(int start, int end, double t) { + return Math.max(0, Math.min(255, (int) Math.round(start + ((end - start) * t)))); + } + + private static double clamp(double value, double min, double max) { + return Math.max(min, Math.min(max, value)); + } + + private static Point offsetPoint(int x, int y, NodeWidget.PortSide side, double offset) { + return new Point( + (int) Math.round(x + (side.normalX() * offset)), + (int) Math.round(y + (side.normalY() * offset)) + ); + } + + private static void appendPoint(List points, Point point) { + if (!points.isEmpty() && points.getLast().equals(point)) { + return; + } + points.add(point); + } + + record CurveControls(double control1X, double control1Y, double control2X, double control2Y) { + } + + private record Point(int x, int y) { + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/EdgeStyle.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/EdgeStyle.java new file mode 100644 index 0000000..5109564 --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/EdgeStyle.java @@ -0,0 +1,7 @@ +package com.github.squi2rel.mcng.fabric.client; + +public enum EdgeStyle { + STRAIGHT, + CURVE, + ORTHOGONAL +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/EditorStyleRenderer.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/EditorStyleRenderer.java new file mode 100644 index 0000000..f87c971 --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/EditorStyleRenderer.java @@ -0,0 +1,178 @@ +package com.github.squi2rel.mcng.fabric.client; + +import net.minecraft.client.gui.GuiGraphicsExtractor; + +final class EditorStyleRenderer { + private static final int DEFAULT_RADIUS = 6; + + private EditorStyleRenderer() { + } + + static void drawBorder(GuiGraphicsExtractor context, int x, int y, int width, int height, int color) { + context.fill(x, y, x + width, y + 1, color); + context.fill(x, y + height - 1, x + width, y + height, color); + context.fill(x, y + 1, x + 1, y + height - 1, color); + context.fill(x + width - 1, y + 1, x + width, y + height - 1, color); + } + + static void drawBox(GuiGraphicsExtractor context, int x, int y, int width, int height, int fillColor, int borderColor, GraphEditorUiConfig config) { + if (config.nodeCornerStyle() == NodeCornerStyle.ROUNDED) { + fillRoundedRect(context, x, y, width, height, fillColor, DEFAULT_RADIUS); + drawRoundedOutline(context, x, y, width, height, borderColor, DEFAULT_RADIUS); + return; + } + context.fill(x, y, x + width, y + height, fillColor); + drawBorder(context, x, y, width, height, borderColor); + } + + static void drawNodeBox(GuiGraphicsExtractor context, NodeWidget widget, int bodyColor, int headerColor, int borderColor, GraphEditorUiConfig config) { + drawBox(context, widget.x(), widget.y(), widget.width(), widget.height(), bodyColor, borderColor, config); + if (!widget.hasHeader()) { + return; + } + if (config.nodeCornerStyle() == NodeCornerStyle.ROUNDED) { + fillTopRoundedRect(context, widget.x() + 1, widget.y() + 1, widget.width() - 2, Math.max(1, widget.headerHeight() - 1), headerColor, DEFAULT_RADIUS - 1); + return; + } + context.fill( + widget.x() + 1, + widget.y() + 1, + widget.x() + widget.width() - 1, + widget.y() + widget.headerHeight(), + headerColor + ); + } + + static void drawPort(GuiGraphicsExtractor context, int centerX, int centerY, int radius, int color, PortShape portShape) { + if (portShape == PortShape.SQUARE) { + context.fill(centerX - radius, centerY - radius, centerX + radius + 1, centerY + radius + 1, color); + return; + } + fillCircle(context, centerX, centerY, radius, color); + } + + 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 brighten(int color, float amount) { + return blend(color, 0xFFFFFFFF, amount); + } + + static int darken(int color, float amount) { + return blend(color, 0xFF000000, amount); + } + + private static void fillCircle(GuiGraphicsExtractor context, int centerX, int centerY, int radius, int color) { + for (int dy = -radius; dy <= radius; dy++) { + double distance = Math.sqrt(Math.max(0, (radius * radius) - (dy * dy))); + int xOffset = (int) Math.floor(distance); + context.fill(centerX - xOffset, centerY + dy, centerX + xOffset + 1, centerY + dy + 1, color); + } + } + + private static void fillRoundedRect(GuiGraphicsExtractor context, int x, int y, int width, int height, int color, int radius) { + if (width <= 0 || height <= 0) { + return; + } + int clampedRadius = clampRadius(width, height, radius); + if (clampedRadius <= 0) { + context.fill(x, y, x + width, y + height, color); + return; + } + + for (int row = 0; row < height; row++) { + int inset = insetForRow(height, clampedRadius, row); + context.fill(x + inset, y + row, x + width - inset, y + row + 1, color); + } + } + + private static void drawRoundedOutline(GuiGraphicsExtractor context, int x, int y, int width, int height, int color, int radius) { + if (width <= 0 || height <= 0) { + return; + } + int clampedRadius = clampRadius(width, height, radius); + if (clampedRadius <= 0) { + drawBorder(context, x, y, width, height, color); + return; + } + + int innerX = x + 1; + int innerY = y + 1; + int innerWidth = width - 2; + int innerHeight = height - 2; + int innerRadius = innerWidth > 0 && innerHeight > 0 ? clampRadius(innerWidth, innerHeight, Math.max(0, clampedRadius - 1)) : 0; + for (int row = 0; row < height; row++) { + int outerInset = insetForRow(height, clampedRadius, row); + int outerLeft = x + outerInset; + int outerRight = x + width - outerInset - 1; + if (outerLeft > outerRight) { + continue; + } + + int innerRow = row - 1; + if (innerWidth <= 0 || innerHeight <= 0 || innerRow < 0 || innerRow >= innerHeight) { + context.fill(outerLeft, y + row, outerRight + 1, y + row + 1, color); + continue; + } + + int innerInset = insetForRow(innerHeight, innerRadius, innerRow); + int innerLeft = innerX + innerInset; + int innerRight = innerX + innerWidth - innerInset - 1; + if (innerLeft > innerRight) { + context.fill(outerLeft, y + row, outerRight + 1, y + row + 1, color); + continue; + } + if (outerLeft < innerLeft) { + context.fill(outerLeft, y + row, innerLeft, y + row + 1, color); + } + if (innerRight < outerRight) { + context.fill(innerRight + 1, y + row, outerRight + 1, y + row + 1, color); + } + } + } + + private static void fillTopRoundedRect(GuiGraphicsExtractor context, int x, int y, int width, int height, int color, int radius) { + if (width <= 0 || height <= 0) { + return; + } + int clampedRadius = clampRadius(width, height * 2, radius); + if (clampedRadius <= 0 || height <= clampedRadius) { + fillRoundedRect(context, x, y, width, height, color, clampedRadius); + return; + } + + for (int row = 0; row < height; row++) { + int inset = row < clampedRadius ? cornerInset(clampedRadius, row) : 0; + context.fill(x + inset, y + row, x + width - inset, y + row + 1, color); + } + } + + private static int clampRadius(int width, int height, int radius) { + return Math.max(0, Math.min(radius, Math.min(width, height) / 2)); + } + + private static int insetForRow(int height, int radius, int row) { + if (radius <= 0) { + return 0; + } + int mirroredRow = Math.min(row, height - row - 1); + if (mirroredRow >= radius) { + return 0; + } + return cornerInset(radius, mirroredRow); + } + + private static int cornerInset(int radius, int row) { + double dy = (radius - row) - 0.5; + return Math.max(0, (int) Math.floor(radius - Math.sqrt(Math.max(0.0, (radius * radius) - (dy * dy))))); + } + + 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/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphCanvasComponent.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphCanvasComponent.java new file mode 100644 index 0000000..8fd6a46 --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphCanvasComponent.java @@ -0,0 +1,1054 @@ +package com.github.squi2rel.mcng.fabric.client; + +import com.github.squi2rel.mcng.core.EdgeDefinition; +import com.github.squi2rel.mcng.core.GraphLayout; +import com.github.squi2rel.mcng.core.NodeConfigValues; +import com.github.squi2rel.mcng.core.NodeEditorControl; +import com.github.squi2rel.mcng.core.NodeId; +import com.github.squi2rel.mcng.core.NodeInstance; +import com.github.squi2rel.mcng.core.NumericTypes; +import com.github.squi2rel.mcng.core.PortChannel; +import com.github.squi2rel.mcng.core.PortDirection; +import com.github.squi2rel.mcng.core.PortId; +import com.github.squi2rel.mcng.fabric.client.GraphEditorSession.PendingConnection; +import com.github.squi2rel.mcng.fabric.client.GraphInteractionController.SelectionBox; +import com.github.squi2rel.mcng.fabric.client.NodeWidget.InlineHit; +import com.github.squi2rel.mcng.fabric.client.NodeWidget.PortWidget; +import com.google.gson.JsonPrimitive; +import org.lwjgl.glfw.GLFW; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphicsExtractor; + +public final class GraphCanvasComponent { + private final GraphEditorSession session; + private final GraphViewportState viewport = new GraphViewportState(); + private final GraphInteractionController controller; + private final Supplier uiConfigSupplier; + private final NodeComponentRegistry componentRegistry; + private final Map bodyComponents = new LinkedHashMap<>(); + private Font textRenderer; + private ActiveTextEdit activeTextEdit; + private NodeId focusedBodyNodeId; + private CapturedBodyInteraction capturedBodyInteraction; + private NodeId lastPrimaryClickNodeId; + private long lastPrimaryClickAt; + private GraphEditorBounds bounds = new GraphEditorBounds(0, 0, 0, 0); + + public GraphCanvasComponent(GraphEditorSession session, GraphInteractionController controller, Supplier uiConfigSupplier) { + this(session, controller, uiConfigSupplier, new NodeComponentRegistry()); + } + + public GraphCanvasComponent(GraphEditorSession session, GraphInteractionController controller, Supplier uiConfigSupplier, NodeComponentRegistry componentRegistry) { + this.session = session; + this.controller = controller; + this.uiConfigSupplier = uiConfigSupplier; + this.componentRegistry = componentRegistry; + viewport.reset(); + } + + public void init(Font textRenderer, GraphEditorBounds bounds) { + this.textRenderer = textRenderer; + setBounds(bounds); + } + + public void close() { + if (capturedBodyInteraction != null) { + session.endCompositeEdit(); + capturedBodyInteraction = null; + } + blurActiveBodyComponent(); + for (NodeBodyComponent component : bodyComponents.values()) { + component.close(); + } + bodyComponents.clear(); + } + + public void setBounds(GraphEditorBounds bounds) { + this.bounds = bounds; + } + + public GraphEditorSession session() { + return session; + } + + public GraphViewportState viewport() { + return viewport; + } + + public GraphEditorBounds bounds() { + return bounds; + } + + public boolean isTextEditing() { + return activeTextEdit != null; + } + + public boolean contains(double mouseX, double mouseY) { + return bounds.contains(mouseX, mouseY); + } + + public void render(GuiGraphicsExtractor context, Font textRenderer, int mouseX, int mouseY) { + this.textRenderer = textRenderer; + GraphEditorUiConfig uiConfig = uiConfigSupplier.get(); + GraphEditorTheme theme = uiConfig.theme(); + renderGrid(context, theme); + + context.enableScissor(bounds.x(), bounds.y(), bounds.right(), bounds.bottom()); + try { + List widgets = widgets(); + context.pose().pushMatrix(); + context.pose().translate(bounds.x(), bounds.y()); + context.pose().translate((float) viewport.offsetX(), (float) viewport.offsetY()); + context.pose().scale((float) viewport.zoom(), (float) viewport.zoom()); + try { + for (EdgeDefinition edge : session.edges()) { + NodeWidget fromNode = widgets.stream().filter(widget -> widget.node().id().equals(edge.fromNodeId())).findFirst().orElse(null); + NodeWidget toNode = widgets.stream().filter(widget -> widget.node().id().equals(edge.toNodeId())).findFirst().orElse(null); + if (fromNode == null || toNode == null) { + continue; + } + + PortWidget fromPort = findPortWidget(fromNode, edge.fromPortId(), PortDirection.OUTPUT); + PortWidget toPort = findPortWidget(toNode, edge.toPortId(), PortDirection.INPUT); + if (fromPort != null && toPort != null) { + EdgeRenderer.drawEdge( + context, + fromPort.centerX(), + fromPort.centerY(), + toPort.centerX(), + toPort.centerY(), + portColor(fromPort, theme), + portColor(toPort, theme), + uiConfig.edgeStyle(), + fromPort.side(), + toPort.side() + ); + } + } + + PendingConnection pending = session.pendingConnection(); + if (pending != null) { + PortWidget pendingPort = widgets.stream() + .flatMap(widget -> widget.ports().stream()) + .filter(port -> port.nodeId().equals(pending.nodeId()) + && port.definition().id().equals(pending.portId()) + && port.definition().direction() == pending.direction()) + .findFirst() + .orElse(null); + if (pendingPort != null) { + int color = EditorStyleRenderer.brighten(portColor(pendingPort, theme), 0.22f); + PendingEdgeGeometry geometry = pendingEdgeGeometry( + pendingPort, + (int) Math.round(toWorldX(mouseX)), + (int) Math.round(toWorldY(mouseY)) + ); + EdgeRenderer.drawEdge( + context, + geometry.startX(), + geometry.startY(), + geometry.endX(), + geometry.endY(), + color, + color, + uiConfig.edgeStyle(), + geometry.startSide(), + geometry.endSide() + ); + } + } + + for (NodeWidget widget : widgets) { + NodeWidgetRenderer.render( + context, + textRenderer, + widget, + uiConfig, + theme, + session.isSelected(widget.node().id()), + session.isExecuting(widget.node().id()), + session.hasError(widget.node().id()), + port -> portColor(port, theme), + port -> session.pendingConnection() != null + && session.pendingConnection().nodeId().equals(port.nodeId()) + && session.pendingConnection().portId().equals(port.definition().id()), + (nodeId, portId) -> activeTextEdit != null && activeTextEdit.matchesPort(nodeId, portId), + (nodeId, key) -> activeTextEdit != null && activeTextEdit.matchesControl(nodeId, key) + ); + } + SelectionBox selectionBox = controller.selectionBox(); + if (selectionBox != null) { + context.fill((int) selectionBox.minX(), (int) selectionBox.minY(), (int) selectionBox.maxX(), (int) selectionBox.maxY(), 0x223B82F6); + EditorStyleRenderer.drawBorder(context, (int) selectionBox.minX(), (int) selectionBox.minY(), (int) Math.max(1, selectionBox.maxX() - selectionBox.minX()), (int) Math.max(1, selectionBox.maxY() - selectionBox.minY()), 0xFF6EA8FF); + } + } finally { + context.pose().popMatrix(); + } + } finally { + context.disableScissor(); + } + + renderActiveTextEditor(context, textRenderer, theme, uiConfig); + } + + public void prepareForClick(double mouseX, double mouseY, int button) { + double worldX = toWorldX(mouseX); + double worldY = toWorldY(mouseY); + if (button == 0 && activeTextEdit != null && !activeTextEdit.bounds().contains(worldX, worldY)) { + commitActiveTextEdit(); + } + if (button == 0 && focusedBodyNodeId != null) { + NodeWidget focusedWidget = widgetById(focusedBodyNodeId); + if (focusedWidget == null || !focusedWidget.hasInteractiveBody() || !focusedWidget.bodyBounds().contains(worldX, worldY)) { + blurActiveBodyComponent(); + } + } + } + + public boolean mouseClicked(double mouseX, double mouseY, int button) { + double worldX = toWorldX(mouseX); + double worldY = toWorldY(mouseY); + if (button == 0 && activeTextEdit != null && activeTextEdit.bounds().contains(worldX, toWorldY(mouseY))) { + activeTextEdit.handlePointerDown(textRenderer, worldX, System.currentTimeMillis()); + return true; + } + + if (button == 0) { + NodeWidget clickedNode = nodeAt(mouseX, mouseY); + long now = System.currentTimeMillis(); + if (!shiftDown() + && clickedNode != null + && session.canEnterDefinition(clickedNode.node().id()) + && clickedNode.node().id().equals(lastPrimaryClickNodeId) + && now - lastPrimaryClickAt <= 250L) { + if (session.enterDefinition(clickedNode.node().id())) { + viewport.reset(); + lastPrimaryClickNodeId = null; + lastPrimaryClickAt = 0L; + return true; + } + } + lastPrimaryClickNodeId = clickedNode != null ? clickedNode.node().id() : null; + lastPrimaryClickAt = now; + + InlineHit inlineHit = inlineHitAt(mouseX, mouseY); + if (inlineHit != null) { + handleInlineHit(inlineHit, worldX); + return true; + } + if (handleBodyClick(worldX, worldY, button)) { + return true; + } + } else if (handleBodyClick(worldX, worldY, button)) { + return true; + } + + return controller.mouseClicked(this, localX(mouseX), localY(mouseY), button); + } + + public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) { + if (activeTextEdit != null && button == 0) { + activeTextEdit.handlePointerDrag(textRenderer, toWorldX(mouseX)); + return true; + } + if (capturedBodyInteraction != null && capturedBodyInteraction.button() == button) { + NodeWidget widget = widgetById(capturedBodyInteraction.nodeId()); + if (widget != null && widget.hasInteractiveBody()) { + double worldX = toWorldX(mouseX); + double worldY = toWorldY(mouseY); + NodeInteractionResult result = widget.bodyComponent().mouseDragged( + bodyInputContext(widget), + worldX - widget.bodyBounds().x(), + worldY - widget.bodyBounds().y(), + button, + deltaX / viewport.zoom(), + deltaY / viewport.zoom() + ); + applyBodyInteractionResult(widget, result, button); + return true; + } + session.endCompositeEdit(); + capturedBodyInteraction = null; + } + return controller.mouseDragged(this, localX(mouseX), localY(mouseY), button, deltaX, deltaY); + } + + public boolean mouseReleased(double mouseX, double mouseY, int button) { + if (activeTextEdit != null && button == 0) { + activeTextEdit.finishPointerDrag(); + return true; + } + if (capturedBodyInteraction != null && capturedBodyInteraction.button() == button) { + NodeWidget widget = widgetById(capturedBodyInteraction.nodeId()); + capturedBodyInteraction = null; + if (widget != null && widget.hasInteractiveBody()) { + double worldX = toWorldX(mouseX); + double worldY = toWorldY(mouseY); + NodeInteractionResult result = widget.bodyComponent().mouseReleased( + bodyInputContext(widget), + worldX - widget.bodyBounds().x(), + worldY - widget.bodyBounds().y(), + button + ); + applyBodyInteractionResult(widget, result, button); + } + session.endCompositeEdit(); + return true; + } + return controller.mouseReleased(this, localX(mouseX), localY(mouseY), button); + } + + public boolean mouseScrolled(double mouseX, double mouseY, double horizontalAmount, double verticalAmount) { + if (activeTextEdit != null) { + return true; + } + double worldX = toWorldX(mouseX); + double worldY = toWorldY(mouseY); + NodeWidget bodyWidget = bodyWidgetAtWorld(worldX, worldY); + if (bodyWidget != null) { + session.beginCompositeEdit(); + NodeInteractionResult result = bodyWidget.bodyComponent().mouseScrolled( + bodyInputContext(bodyWidget), + worldX - bodyWidget.bodyBounds().x(), + worldY - bodyWidget.bodyBounds().y(), + horizontalAmount, + verticalAmount + ); + boolean handled = applyBodyInteractionResult(bodyWidget, result, -1); + session.endCompositeEdit(); + if (handled) { + return true; + } + } + return controller.mouseScrolled(this, localX(mouseX), localY(mouseY), horizontalAmount, verticalAmount); + } + + public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + if (activeTextEdit != null) { + boolean controlDown = (modifiers & GLFW.GLFW_MOD_CONTROL) != 0; + boolean shiftDown = (modifiers & GLFW.GLFW_MOD_SHIFT) != 0; + if (keyCode == GLFW.GLFW_KEY_ESCAPE) { + cancelActiveTextEdit(); + return true; + } + if (keyCode == GLFW.GLFW_KEY_ENTER || keyCode == GLFW.GLFW_KEY_KP_ENTER) { + commitActiveTextEdit(); + return true; + } + if (controlDown) { + switch (keyCode) { + case GLFW.GLFW_KEY_Z -> { + boolean changed = shiftDown ? activeTextEdit.state().redo() : activeTextEdit.state().undo(); + if (changed) { + activeTextEdit.ensureCursorVisible(textRenderer); + } + return true; + } + case GLFW.GLFW_KEY_A -> { + activeTextEdit.state().selectAll(); + activeTextEdit.ensureCursorVisible(textRenderer); + return true; + } + case GLFW.GLFW_KEY_C -> { + session.copyToClipboard(activeTextEdit.state().selectedText()); + return true; + } + case GLFW.GLFW_KEY_X -> { + session.copyToClipboard(activeTextEdit.state().selectedText()); + activeTextEdit.state().insert(""); + activeTextEdit.ensureCursorVisible(textRenderer); + return true; + } + case GLFW.GLFW_KEY_V -> { + activeTextEdit.state().insert(session.readClipboard()); + activeTextEdit.ensureCursorVisible(textRenderer); + return true; + } + default -> { + } + } + } + + switch (keyCode) { + case GLFW.GLFW_KEY_LEFT -> activeTextEdit.state().moveLeft(controlDown, shiftDown); + case GLFW.GLFW_KEY_RIGHT -> activeTextEdit.state().moveRight(controlDown, shiftDown); + case GLFW.GLFW_KEY_HOME -> activeTextEdit.state().moveHome(shiftDown); + case GLFW.GLFW_KEY_END -> activeTextEdit.state().moveEnd(shiftDown); + case GLFW.GLFW_KEY_BACKSPACE -> activeTextEdit.state().backspace(controlDown); + case GLFW.GLFW_KEY_DELETE -> activeTextEdit.state().delete(controlDown); + default -> { + return false; + } + } + activeTextEdit.ensureCursorVisible(textRenderer); + return true; + } + NodeWidget focusedWidget = focusedBodyNodeId == null ? null : widgetById(focusedBodyNodeId); + if (focusedWidget != null && focusedWidget.hasInteractiveBody()) { + return focusedWidget.bodyComponent().keyPressed(bodyInputContext(focusedWidget), keyCode, scanCode, modifiers); + } + return false; + } + + public boolean charTyped(char chr, int modifiers) { + if (activeTextEdit != null) { + if ((modifiers & (GLFW.GLFW_MOD_CONTROL | GLFW.GLFW_MOD_ALT)) != 0 || Character.isISOControl(chr)) { + return false; + } + activeTextEdit.state().insert(String.valueOf(chr)); + activeTextEdit.ensureCursorVisible(textRenderer); + return true; + } + NodeWidget focusedWidget = focusedBodyNodeId == null ? null : widgetById(focusedBodyNodeId); + if (focusedWidget != null && focusedWidget.hasInteractiveBody()) { + return focusedWidget.bodyComponent().charTyped(bodyInputContext(focusedWidget), chr, modifiers); + } + return false; + } + + public void commitInlineEditor() { + commitActiveTextEdit(); + } + + public NodeWidget nodeAt(double mouseX, double mouseY) { + double worldX = toWorldX(mouseX); + double worldY = toWorldY(mouseY); + return nodeAtWorld(worldX, worldY); + } + + public NodeWidget nodeAtLocal(double localX, double localY) { + double worldX = viewport.toWorldX(localX); + double worldY = viewport.toWorldY(localY); + return nodeAtWorld(worldX, worldY); + } + + private NodeWidget nodeAtWorld(double worldX, double worldY) { + List widgets = widgets(); + for (int index = widgets.size() - 1; index >= 0; index--) { + NodeWidget widget = widgets.get(index); + if (widget.contains(worldX, worldY)) { + return widget; + } + } + return null; + } + + public PortWidget portAt(double mouseX, double mouseY) { + double worldX = toWorldX(mouseX); + double worldY = toWorldY(mouseY); + return portAtWorld(worldX, worldY); + } + + public PortWidget portAtLocal(double localX, double localY) { + double worldX = viewport.toWorldX(localX); + double worldY = viewport.toWorldY(localY); + return portAtWorld(worldX, worldY); + } + + private PortWidget portAtWorld(double worldX, double worldY) { + NodeWidget widget = nodeAtWorld(worldX, worldY); + return widget == null ? null : widget.findPortAt(worldX, worldY); + } + + public ResizeTarget resizeTargetAtLocal(double localX, double localY) { + double worldX = viewport.toWorldX(localX); + double worldY = viewport.toWorldY(localY); + return resizeTargetAtWorld(worldX, worldY); + } + + public ResizeDirection currentResizeDirection(double mouseX, double mouseY) { + ResizeDirection activeDirection = controller.activeResizeDirection(); + if (activeDirection != null) { + return activeDirection; + } + if (!contains(mouseX, mouseY)) { + return null; + } + ResizeTarget target = resizeTargetAtLocal(localX(mouseX), localY(mouseY)); + return target == null ? null : target.direction(); + } + + public GraphCursorManager.CursorKind cursorKindAt(double mouseX, double mouseY) { + ResizeDirection activeDirection = controller.activeResizeDirection(); + if (activeDirection != null) { + return GraphCursorManager.forResizeDirection(activeDirection); + } + if (!contains(mouseX, mouseY)) { + return GraphCursorManager.CursorKind.DEFAULT; + } + + double worldX = toWorldX(mouseX); + double worldY = toWorldY(mouseY); + if (activeTextEdit != null && activeTextEdit.bounds().contains(worldX, worldY)) { + return GraphCursorManager.CursorKind.TEXT; + } + + List widgets = widgets(); + for (int index = widgets.size() - 1; index >= 0; index--) { + NodeWidget widget = widgets.get(index); + if (!widget.contains(worldX, worldY)) { + continue; + } + + NodeWidget.ResizeHandle resizeHandle = widget.findResizeHandleAt(worldX, worldY); + if (resizeHandle != null) { + return GraphCursorManager.forResizeDirection(resizeHandle.direction()); + } + + if (widget.findPortAt(worldX, worldY) != null) { + return GraphCursorManager.CursorKind.DEFAULT; + } + + InlineHit inlineHit = widget.findInlineHitAt(worldX, worldY); + if (inlineHit != null) { + return switch (inlineHit) { + case InlineHit.PortTextFieldHit ignored -> GraphCursorManager.CursorKind.TEXT; + case InlineHit.ControlTextFieldHit ignored -> GraphCursorManager.CursorKind.TEXT; + case InlineHit.PortBooleanHit ignored -> GraphCursorManager.CursorKind.DEFAULT; + case InlineHit.ControlBooleanHit ignored -> GraphCursorManager.CursorKind.DEFAULT; + case InlineHit.ControlCycleHit ignored -> GraphCursorManager.CursorKind.DEFAULT; + }; + } + + if (widget.hasInteractiveBody() && widget.bodyBounds().contains(worldX, worldY)) { + return GraphCursorManager.CursorKind.DEFAULT; + } + + return GraphCursorManager.CursorKind.GRAB; + } + return GraphCursorManager.CursorKind.DEFAULT; + } + + private ResizeTarget resizeTargetAtWorld(double worldX, double worldY) { + List widgets = widgets(); + for (int index = widgets.size() - 1; index >= 0; index--) { + NodeWidget widget = widgets.get(index); + if (!widget.contains(worldX, worldY)) { + continue; + } + NodeWidget.ResizeHandle handle = widget.findResizeHandleAt(worldX, worldY); + if (handle != null) { + return new ResizeTarget(widget, handle.direction()); + } + } + return null; + } + + public List nodesInRect(double minX, double minY, double maxX, double maxY) { + List nodeIds = new ArrayList<>(); + for (NodeWidget widget : widgets()) { + if (widget.x() <= maxX && widget.x() + widget.width() >= minX && widget.y() <= maxY && widget.y() + widget.height() >= minY) { + nodeIds.add(widget.node().id()); + } + } + return nodeIds; + } + + private void renderGrid(GuiGraphicsExtractor context, GraphEditorTheme theme) { + context.fill(bounds.x(), bounds.y(), bounds.right(), bounds.bottom(), theme.canvasBackgroundColor()); + context.pose().pushMatrix(); + context.pose().translate(bounds.x(), bounds.y()); + try { + double step = Math.max(8, 24 * viewport.zoom()); + double startX = viewport.offsetX() % step; + double startY = viewport.offsetY() % step; + for (double x = startX; x < bounds.width(); x += step) { + context.fill((int) x, 0, (int) x + 1, bounds.height(), theme.gridColor()); + } + for (double y = startY; y < bounds.height(); y += step) { + context.fill(0, (int) y, bounds.width(), (int) y + 1, theme.gridColor()); + } + } finally { + context.pose().popMatrix(); + } + } + + private void renderActiveTextEditor(GuiGraphicsExtractor context, Font textRenderer, GraphEditorTheme theme, GraphEditorUiConfig uiConfig) { + if (activeTextEdit == null) { + return; + } + context.enableScissor(bounds.x(), bounds.y(), bounds.right(), bounds.bottom()); + try { + context.pose().pushMatrix(); + try { + context.pose().translate(bounds.x(), bounds.y()); + context.pose().translate((float) viewport.offsetX(), (float) viewport.offsetY()); + context.pose().scale((float) viewport.zoom(), (float) viewport.zoom()); + GraphTextInputRenderer.renderFrame(context, activeTextEdit.bounds(), theme, uiConfig); + } finally { + context.pose().popMatrix(); + } + + NodeWidget.Bounds screenBounds = screenBounds(activeTextEdit.bounds()); + int scissorLeft = Math.max(bounds.x(), screenBounds.x() + GraphTextInputRenderer.CONTENT_PADDING_X); + int scissorTop = Math.max(bounds.y(), screenBounds.y() + GraphTextInputRenderer.CONTENT_PADDING_Y); + int scissorRight = Math.min(bounds.right(), screenBounds.x() + screenBounds.width() - GraphTextInputRenderer.CONTENT_PADDING_X); + int scissorBottom = Math.min(bounds.bottom(), screenBounds.y() + screenBounds.height() - GraphTextInputRenderer.CONTENT_PADDING_Y); + context.enableScissor(scissorLeft, scissorTop, scissorRight, scissorBottom); + try { + context.pose().pushMatrix(); + try { + context.pose().translate(bounds.x(), bounds.y()); + context.pose().translate((float) viewport.offsetX(), (float) viewport.offsetY()); + context.pose().scale((float) viewport.zoom(), (float) viewport.zoom()); + GraphTextInputRenderer.renderContent(context, textRenderer, activeTextEdit.bounds(), activeTextEdit.state(), theme, true); + } finally { + context.pose().popMatrix(); + } + } finally { + context.disableScissor(); + } + } finally { + context.disableScissor(); + } + } + + private void handleInlineHit(InlineHit inlineHit, double worldX) { + switch (inlineHit) { + case InlineHit.PortTextFieldHit portTextFieldHit -> { + session.selectNode(portTextFieldHit.nodeId()); + NodeInstance node = session.node(portTextFieldHit.nodeId()); + var port = session.nodeType(node).inputs(node).stream().filter(candidate -> candidate.id().equals(portTextFieldHit.portId())).findFirst().orElseThrow(); + String value = portTextFieldHit.numeric() + ? NodeConfigValues.readInlineInputText(node.config(), port) + : String.valueOf(NodeConfigValues.readInlineInputValue(node.config(), port)); + beginTextEdit(ActiveTextEdit.forPort(portTextFieldHit, value)); + activeTextEdit.handlePointerDown(textRenderer, worldX, System.currentTimeMillis()); + } + case InlineHit.PortBooleanHit portBooleanHit -> { + session.selectNode(portBooleanHit.nodeId()); + NodeInstance node = session.node(portBooleanHit.nodeId()); + var port = session.nodeType(node).inputs(node).stream().filter(candidate -> candidate.id().equals(portBooleanHit.portId())).findFirst().orElseThrow(); + boolean current = Boolean.TRUE.equals(NodeConfigValues.readInlineInputValue(node.config(), port)); + session.updateInlineInput(node.id(), port.id(), new JsonPrimitive(!current)); + } + case InlineHit.ControlTextFieldHit controlTextFieldHit -> { + session.selectNode(controlTextFieldHit.nodeId()); + NodeInstance node = session.node(controlTextFieldHit.nodeId()); + String value = session.nodeType(node).editorControls().stream() + .filter(candidate -> candidate.key().equals(controlTextFieldHit.key())) + .findFirst() + .map(control -> switch (control) { + case NodeEditorControl.TextControl textControl -> NodeConfigValues.readTextControlValue(node.config(), textControl); + case NodeEditorControl.NumericTextControl numericTextControl -> NodeConfigValues.readNumericTextControlValue(node.config(), numericTextControl); + case NodeEditorControl.BooleanControl ignored -> throw new IllegalStateException("Boolean control is not text-editable"); + case NodeEditorControl.CycleControl ignored -> throw new IllegalStateException("Cycle control is not text-editable"); + }) + .orElseThrow(); + beginTextEdit(ActiveTextEdit.forControl(controlTextFieldHit, value)); + activeTextEdit.handlePointerDown(textRenderer, worldX, System.currentTimeMillis()); + } + case InlineHit.ControlBooleanHit controlBooleanHit -> { + session.selectNode(controlBooleanHit.nodeId()); + NodeInstance node = session.node(controlBooleanHit.nodeId()); + var control = session.nodeType(node).editorControls().stream() + .filter(candidate -> candidate instanceof NodeEditorControl.BooleanControl booleanControl && booleanControl.key().equals(controlBooleanHit.key())) + .map(NodeEditorControl.BooleanControl.class::cast) + .findFirst() + .orElseThrow(); + boolean current = NodeConfigValues.readBooleanControlValue(node.config(), control); + session.updateControlValue(node.id(), control.key(), new JsonPrimitive(!current)); + } + case InlineHit.ControlCycleHit controlCycleHit -> { + session.selectNode(controlCycleHit.nodeId()); + NodeInstance node = session.node(controlCycleHit.nodeId()); + var control = session.nodeType(node).editorControls().stream() + .filter(candidate -> candidate instanceof NodeEditorControl.CycleControl cycleControl && cycleControl.key().equals(controlCycleHit.key())) + .map(NodeEditorControl.CycleControl.class::cast) + .findFirst() + .orElseThrow(); + String current = NodeConfigValues.readCycleControlValue(node.config(), control); + List options = control.options(); + int currentIndex = 0; + for (int index = 0; index < options.size(); index++) { + if (options.get(index).id().equals(current)) { + currentIndex = index; + break; + } + } + int nextIndex = Math.floorMod(currentIndex + controlCycleHit.direction(), options.size()); + session.updateControlValue(node.id(), control.key(), new JsonPrimitive(options.get(nextIndex).id())); + } + } + } + + private void beginTextEdit(ActiveTextEdit edit) { + if (textRenderer == null) { + return; + } + blurActiveBodyComponent(); + activeTextEdit = edit; + activeTextEdit.ensureCursorVisible(textRenderer); + } + + private void commitActiveTextEdit() { + if (activeTextEdit == null) { + return; + } + String text = activeTextEdit.state().text(); + if (activeTextEdit.numeric()) { + try { + String normalized = NumericTypes.parseLiteral(text).text(); + if (activeTextEdit.portId() != null) { + session.updateInlineInput(activeTextEdit.nodeId(), activeTextEdit.portId(), new JsonPrimitive(normalized)); + } else { + session.updateControlValue(activeTextEdit.nodeId(), activeTextEdit.key(), new JsonPrimitive(normalized)); + } + clearActiveTextEdit(); + } catch (IllegalArgumentException exception) { + session.showMessage(session.translate("mcng.ui.canvas.invalid_number", "Invalid number: %s", text)); + } + return; + } + + if (activeTextEdit.portId() != null) { + session.updateInlineInput(activeTextEdit.nodeId(), activeTextEdit.portId(), new JsonPrimitive(text)); + } else { + session.updateControlValue(activeTextEdit.nodeId(), activeTextEdit.key(), new JsonPrimitive(text)); + } + clearActiveTextEdit(); + } + + private void cancelActiveTextEdit() { + clearActiveTextEdit(); + } + + private void clearActiveTextEdit() { + activeTextEdit = null; + } + + private boolean handleBodyClick(double worldX, double worldY, int button) { + NodeWidget widget = bodyWidgetAtWorld(worldX, worldY); + if (widget == null) { + return false; + } + if (!session.isSelected(widget.node().id()) && !GraphInputModifiers.shiftDown()) { + session.selectNode(widget.node().id()); + } + session.beginCompositeEdit(); + NodeInteractionResult result = widget.bodyComponent().mouseClicked( + bodyInputContext(widget), + worldX - widget.bodyBounds().x(), + worldY - widget.bodyBounds().y(), + button + ); + boolean keepCompositeOpen = result != null && result.handled() && result.capturePointer(); + boolean handled = applyBodyInteractionResult(widget, result, button); + if (!keepCompositeOpen) { + session.endCompositeEdit(); + } + return handled; + } + + private boolean applyBodyInteractionResult(NodeWidget widget, NodeInteractionResult result, int button) { + if (result == null || !result.handled()) { + return false; + } + if (result.requestFocus()) { + setFocusedBodyNode(widget.node().id()); + } + if (result.capturePointer()) { + capturedBodyInteraction = new CapturedBodyInteraction(widget.node().id(), button); + } else if (capturedBodyInteraction != null && capturedBodyInteraction.nodeId().equals(widget.node().id()) && capturedBodyInteraction.button() == button) { + capturedBodyInteraction = null; + session.endCompositeEdit(); + } + return true; + } + + private NodeWidget bodyWidgetAtWorld(double worldX, double worldY) { + List widgets = widgets(); + for (int index = widgets.size() - 1; index >= 0; index--) { + NodeWidget widget = widgets.get(index); + if (widget.hasInteractiveBody() && widget.bodyBounds().contains(worldX, worldY)) { + return widget; + } + } + return null; + } + + private NodeWidget widgetById(NodeId nodeId) { + for (NodeWidget widget : widgets()) { + if (widget.node().id().equals(nodeId)) { + return widget; + } + } + return null; + } + + private NodeBodyInputContext bodyInputContext(NodeWidget widget) { + GraphEditorUiConfig uiConfig = uiConfigSupplier.get(); + return new NodeBodyInputContext(widget.bodyBounds(), widget.node(), widget.nodeType(), session, session.i18n(), uiConfig, uiConfig.theme(), widget.zoom()); + } + + private void setFocusedBodyNode(NodeId nodeId) { + if (nodeId.equals(focusedBodyNodeId)) { + return; + } + blurActiveBodyComponent(); + focusedBodyNodeId = nodeId; + } + + private void blurActiveBodyComponent() { + if (focusedBodyNodeId == null) { + return; + } + NodeBodyComponent component = bodyComponents.get(focusedBodyNodeId); + focusedBodyNodeId = null; + if (capturedBodyInteraction != null) { + session.endCompositeEdit(); + } + capturedBodyInteraction = null; + if (component != null) { + component.blur(); + } + } + + private InlineHit inlineHitAt(double mouseX, double mouseY) { + double worldX = toWorldX(mouseX); + double worldY = toWorldY(mouseY); + List widgets = widgets(); + for (int index = widgets.size() - 1; index >= 0; index--) { + NodeWidget widget = widgets.get(index); + if (!widget.contains(worldX, worldY)) { + continue; + } + InlineHit hit = widget.findInlineHitAt(worldX, worldY); + if (hit != null) { + return hit; + } + } + return null; + } + + private List widgets() { + GraphLayout layout = new GraphLayout(session.positions(), session.sizes()); + cleanupBodyComponents(); + GraphEditorUiConfig uiConfig = uiConfigSupplier.get(); + List widgets = new ArrayList<>(); + for (NodeInstance node : session.nodes()) { + NodeComponentDefinition definition = componentRegistry.find(node.typeId()).orElse(null); + NodeBodyComponent component = definition == null ? null : bodyComponents.computeIfAbsent(node.id(), ignored -> definition.factory().create()); + ResizePolicy resizePolicy = definition == null ? ResizePolicy.none() : definition.resizePolicy(); + widgets.add(new NodeWidget(node, session.nodeType(node), session, layout, viewport, uiConfig, component, resizePolicy)); + } + return widgets; + } + + private void cleanupBodyComponents() { + List visibleNodeIds = session.nodes().stream().map(NodeInstance::id).toList(); + List removed = bodyComponents.keySet().stream() + .filter(nodeId -> !visibleNodeIds.contains(nodeId)) + .toList(); + for (NodeId nodeId : removed) { + NodeBodyComponent component = bodyComponents.remove(nodeId); + if (component != null) { + component.close(); + } + } + if (focusedBodyNodeId != null && !visibleNodeIds.contains(focusedBodyNodeId)) { + focusedBodyNodeId = null; + } + if (capturedBodyInteraction != null && !visibleNodeIds.contains(capturedBodyInteraction.nodeId())) { + session.endCompositeEdit(); + capturedBodyInteraction = null; + } + } + + private int portColor(PortWidget port, GraphEditorTheme theme) { + if (session.effectivePortChannel(port.nodeId(), port.definition().id(), port.definition().direction()) == PortChannel.CONTROL) { + return theme.controlFlowColor(); + } + return session.portTypes().colorOf(session.effectivePortType(port.nodeId(), port.definition().id(), port.definition().direction())); + } + + static PendingEdgeGeometry pendingEdgeGeometry(PortWidget pendingPort, int mouseX, int mouseY) { + if (pendingPort.definition().direction() == PortDirection.OUTPUT) { + return new PendingEdgeGeometry( + pendingPort.centerX(), + pendingPort.centerY(), + mouseX, + mouseY, + pendingPort.side(), + targetSideForFreeEndpoint(pendingPort.centerX(), pendingPort.centerY(), mouseX, mouseY) + ); + } + return new PendingEdgeGeometry( + mouseX, + mouseY, + pendingPort.centerX(), + pendingPort.centerY(), + sourceSideForFreeEndpoint(mouseX, mouseY, pendingPort.centerX(), pendingPort.centerY()), + pendingPort.side() + ); + } + + private static NodeWidget.PortSide sourceSideForFreeEndpoint(int sourceX, int sourceY, int targetX, int targetY) { + return sideToward(sourceX, sourceY, targetX, targetY); + } + + private static NodeWidget.PortSide targetSideForFreeEndpoint(int sourceX, int sourceY, int targetX, int targetY) { + return sideToward(targetX, targetY, sourceX, sourceY); + } + + private static NodeWidget.PortSide sideToward(int fromX, int fromY, int toX, int toY) { + int dx = toX - fromX; + int dy = toY - fromY; + if (Math.abs(dx) >= Math.abs(dy)) { + return dx >= 0 ? NodeWidget.PortSide.RIGHT : NodeWidget.PortSide.LEFT; + } + return dy >= 0 ? NodeWidget.PortSide.BOTTOM : NodeWidget.PortSide.TOP; + } + + private static boolean shiftDown() { + return GraphInputModifiers.shiftDown(); + } + + private NodeWidget.Bounds screenBounds(NodeWidget.Bounds bounds) { + int width = Math.max(8, (int) Math.round(bounds.width() * viewport.zoom())); + int height = Math.max(10, (int) Math.round(bounds.height() * viewport.zoom())); + return new NodeWidget.Bounds(this.bounds.x() + (int) Math.round(viewport.toScreenX(bounds.x())), this.bounds.y() + (int) Math.round(viewport.toScreenY(bounds.y())), width, height); + } + + private double localX(double screenX) { + return screenX - bounds.x(); + } + + private double localY(double screenY) { + return screenY - bounds.y(); + } + + private double toWorldX(double screenX) { + return viewport.toWorldX(localX(screenX)); + } + + private double toWorldY(double screenY) { + return viewport.toWorldY(localY(screenY)); + } + + static PortWidget findPortWidget(NodeWidget widget, PortId portId, PortDirection direction) { + return widget.ports().stream() + .filter(port -> port.definition().id().equals(portId) && port.definition().direction() == direction) + .findFirst() + .orElse(null); + } + + record PendingEdgeGeometry( + int startX, + int startY, + int endX, + int endY, + NodeWidget.PortSide startSide, + NodeWidget.PortSide endSide + ) { + } + + record ResizeTarget(NodeWidget widget, ResizeDirection direction) { + } + + private record CapturedBodyInteraction(NodeId nodeId, int button) { + } + + private static final class ActiveTextEdit { + private static final long DOUBLE_CLICK_WINDOW_MS = 250L; + + private final NodeId nodeId; + private final PortId portId; + private final String key; + private final NodeWidget.Bounds bounds; + private final boolean numeric; + private final GraphTextInputState state; + private boolean draggingPointer; + private long lastPointerDownAt; + private int lastPointerIndex; + + private ActiveTextEdit(NodeId nodeId, PortId portId, String key, NodeWidget.Bounds bounds, boolean numeric, String originalValue) { + this.nodeId = nodeId; + this.portId = portId; + this.key = key; + this.bounds = bounds; + this.numeric = numeric; + this.state = new GraphTextInputState(originalValue); + } + + private static ActiveTextEdit forPort(InlineHit.PortTextFieldHit hit, String originalValue) { + return new ActiveTextEdit(hit.nodeId(), hit.portId(), null, hit.bounds(), hit.numeric(), originalValue); + } + + private static ActiveTextEdit forControl(InlineHit.ControlTextFieldHit hit, String originalValue) { + return new ActiveTextEdit(hit.nodeId(), null, hit.key(), hit.bounds(), hit.numeric(), originalValue); + } + + private NodeWidget.Bounds bounds() { + return bounds; + } + + private boolean numeric() { + return numeric; + } + + private NodeId nodeId() { + return nodeId; + } + + private PortId portId() { + return portId; + } + + private String key() { + return key; + } + + private GraphTextInputState state() { + return state; + } + + private void handlePointerDown(Font textRenderer, double worldX, long timeMs) { + int index = indexForWorldX(textRenderer, worldX); + if ((timeMs - lastPointerDownAt) <= DOUBLE_CLICK_WINDOW_MS && Math.abs(index - lastPointerIndex) <= 1) { + state.selectWordAt(index); + draggingPointer = false; + } else { + state.setCursor(index, false); + draggingPointer = true; + } + lastPointerDownAt = timeMs; + lastPointerIndex = index; + ensureCursorVisible(textRenderer); + } + + private void handlePointerDrag(Font textRenderer, double worldX) { + if (!draggingPointer) { + return; + } + state.setCursor(indexForWorldX(textRenderer, worldX), true); + ensureCursorVisible(textRenderer); + } + + private void finishPointerDrag() { + draggingPointer = false; + } + + private void ensureCursorVisible(Font textRenderer) { + state.ensureCursorVisible(textRenderer, Math.max(1, bounds.width() - 8)); + } + + private boolean matchesPort(NodeId nodeId, PortId portId) { + return this.nodeId.equals(nodeId) && this.portId != null && this.portId.equals(portId); + } + + private boolean matchesControl(NodeId nodeId, String key) { + return this.nodeId.equals(nodeId) && this.key != null && this.key.equals(key); + } + + private int indexForWorldX(Font textRenderer, double worldX) { + double localX = worldX - (bounds.x() + 4) + state.scrollX(); + return state.indexForX(textRenderer, localX); + } + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphContextMenuComponent.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphContextMenuComponent.java new file mode 100644 index 0000000..d636de7 --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphContextMenuComponent.java @@ -0,0 +1,135 @@ +package com.github.squi2rel.mcng.fabric.client; + +import java.util.List; +import java.util.function.Supplier; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphicsExtractor; + +public final class GraphContextMenuComponent { + private static final int PADDING = 4; + private static final int ITEM_HEIGHT = 20; + private static final int ITEM_PADDING_X = 10; + private static final int MIN_WIDTH = 96; + private static final int SCREEN_MARGIN = 8; + + private final Supplier uiConfigSupplier; + private final Supplier i18nSupplier; + + private boolean open; + private int x; + private int y; + private int width; + private int height; + private double anchorScreenX; + private double anchorScreenY; + private List items = List.of(); + + public GraphContextMenuComponent(Supplier uiConfigSupplier, Supplier i18nSupplier) { + this.uiConfigSupplier = uiConfigSupplier; + this.i18nSupplier = i18nSupplier; + } + + public void openNodeMenu(double anchorScreenX, double anchorScreenY, GraphEditorBounds bounds, Font textRenderer) { + open(anchorScreenX, anchorScreenY, bounds, textRenderer, List.of( + new MenuItem(GraphEditorTranslations.ui(i18nSupplier.get(), "context_menu.copy", "Copy"), MenuAction.COPY, true), + new MenuItem(GraphEditorTranslations.ui(i18nSupplier.get(), "context_menu.cut", "Cut"), MenuAction.CUT, true), + new MenuItem(GraphEditorTranslations.ui(i18nSupplier.get(), "context_menu.delete", "Delete"), MenuAction.DELETE, true) + )); + } + + public void openCanvasMenu(double anchorScreenX, double anchorScreenY, GraphEditorBounds bounds, Font textRenderer, boolean canPaste) { + open(anchorScreenX, anchorScreenY, bounds, textRenderer, List.of(new MenuItem(GraphEditorTranslations.ui(i18nSupplier.get(), "context_menu.paste", "Paste"), MenuAction.PASTE, canPaste))); + } + + public boolean isOpen() { + return open; + } + + public void close() { + open = false; + items = List.of(); + } + + public boolean blocksCanvasAt(double mouseX, double mouseY) { + return open && mouseX >= x && mouseX <= x + width && mouseY >= y && mouseY <= y + height; + } + + public MenuAction mouseClicked(double mouseX, double mouseY, int button) { + if (!open || button != 0 || !blocksCanvasAt(mouseX, mouseY)) { + return null; + } + MenuItem item = itemAt(mouseX, mouseY); + if (item == null || !item.active()) { + return null; + } + close(); + return item.action(); + } + + public void render(GuiGraphicsExtractor context, Font textRenderer) { + if (!open) { + return; + } + GraphEditorUiConfig uiConfig = uiConfigSupplier.get(); + GraphEditorTheme theme = uiConfig.theme(); + EditorStyleRenderer.drawBox(context, x, y, width, height, theme.panelBackgroundColor(), theme.panelBorderColor(), uiConfig); + + for (int index = 0; index < items.size(); index++) { + MenuItem item = items.get(index); + int itemY = y + PADDING + (index * ITEM_HEIGHT); + int fill = item.active() + ? theme.nodeBodyColor() + : EditorStyleRenderer.darken(theme.panelBackgroundColor(), 0.08f); + int border = item.active() ? theme.panelBorderColor() : EditorStyleRenderer.darken(theme.panelBorderColor(), 0.25f); + EditorStyleRenderer.drawBox(context, x + PADDING, itemY, width - (PADDING * 2), ITEM_HEIGHT - 2, fill, border, uiConfig); + context.text(textRenderer, item.label(), x + PADDING + ITEM_PADDING_X, itemY + 6, item.active() ? theme.primaryTextColor() : theme.secondaryTextColor(), false); + } + } + + double anchorScreenX() { + return anchorScreenX; + } + + double anchorScreenY() { + return anchorScreenY; + } + + private void open(double anchorScreenX, double anchorScreenY, GraphEditorBounds bounds, Font textRenderer, List items) { + this.anchorScreenX = anchorScreenX; + this.anchorScreenY = anchorScreenY; + this.items = List.copyOf(items); + this.width = Math.max(MIN_WIDTH, this.items.stream().mapToInt(item -> textRenderer.width(item.label()) + (ITEM_PADDING_X * 2) + (PADDING * 2)).max().orElse(MIN_WIDTH)); + this.height = (this.items.size() * ITEM_HEIGHT) + (PADDING * 2); + int minX = bounds.x() + SCREEN_MARGIN; + int maxX = Math.max(minX, bounds.right() - width - SCREEN_MARGIN); + int minY = bounds.y() + SCREEN_MARGIN; + int maxY = Math.max(minY, bounds.bottom() - height - SCREEN_MARGIN); + this.x = clamp((int) Math.round(anchorScreenX), minX, maxX); + this.y = clamp((int) Math.round(anchorScreenY), minY, maxY); + this.open = true; + } + + private MenuItem itemAt(double mouseX, double mouseY) { + for (int index = 0; index < items.size(); index++) { + int itemY = y + PADDING + (index * ITEM_HEIGHT); + if (mouseX >= x + PADDING && mouseX <= x + width - PADDING && mouseY >= itemY && mouseY <= itemY + ITEM_HEIGHT - 2) { + return items.get(index); + } + } + return null; + } + + private static int clamp(int value, int min, int max) { + return Math.max(min, Math.min(max, value)); + } + + public enum MenuAction { + COPY, + CUT, + DELETE, + PASTE + } + + private record MenuItem(String label, MenuAction action, boolean active) { + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphCursorManager.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphCursorManager.java new file mode 100644 index 0000000..68bf254 --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphCursorManager.java @@ -0,0 +1,180 @@ +package com.github.squi2rel.mcng.fabric.client; + +import com.sun.jna.Library; +import com.sun.jna.Native; +import com.sun.jna.Pointer; +import org.lwjgl.glfw.GLFW; +import org.lwjgl.glfw.GLFWNativeX11; + +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import net.minecraft.client.Minecraft; + +final class GraphCursorManager { + private static final Map STANDARD_CURSORS = new EnumMap<>(CursorKind.class); + private static CursorKind currentKind = CursorKind.DEFAULT; + + private GraphCursorManager() { + } + + static void apply(CursorKind kind) { + CursorKind nextKind = kind == null ? CursorKind.DEFAULT : kind; + if (nextKind == currentKind) { + return; + } + + Minecraft client = Minecraft.getInstance(); + if (client == null || client.getWindow() == null || client.mouseHandler == null || client.mouseHandler.isMouseGrabbed()) { + return; + } + + long windowHandle = client.getWindow().handle(); + if (X11ThemeCursorSupport.apply(windowHandle, nextKind)) { + currentKind = nextKind; + return; + } + + if (nextKind == CursorKind.DEFAULT) { + X11ThemeCursorSupport.reset(windowHandle); + } + GLFW.glfwSetCursor(windowHandle, cursorHandle(nextKind)); + currentKind = nextKind; + } + + static void reset() { + apply(CursorKind.DEFAULT); + } + + static CursorKind forResizeDirection(ResizeDirection direction) { + if (direction == null) { + return CursorKind.DEFAULT; + } + return switch (direction) { + case LEFT, RIGHT -> CursorKind.RESIZE_EW; + case TOP, BOTTOM -> CursorKind.RESIZE_NS; + case TOP_LEFT, BOTTOM_RIGHT -> CursorKind.RESIZE_NWSE; + case TOP_RIGHT, BOTTOM_LEFT -> CursorKind.RESIZE_NESW; + }; + } + + private static long cursorHandle(CursorKind kind) { + if (kind == CursorKind.DEFAULT) { + return 0L; + } + return STANDARD_CURSORS.computeIfAbsent(kind, ignored -> createStandardCursor(kind)); + } + + private static long createStandardCursor(CursorKind kind) { + for (int shape : kind.glfwShapes()) { + long handle = GLFW.glfwCreateStandardCursor(shape); + if (handle != 0L) { + return handle; + } + } + return 0L; + } + + enum CursorKind { + DEFAULT(), + TEXT(GLFW.GLFW_IBEAM_CURSOR), + GRAB(GLFW.GLFW_HAND_CURSOR), + RESIZE_EW(GLFW.GLFW_RESIZE_EW_CURSOR, GLFW.GLFW_HRESIZE_CURSOR), + RESIZE_NS(GLFW.GLFW_RESIZE_NS_CURSOR, GLFW.GLFW_VRESIZE_CURSOR), + RESIZE_NWSE(GLFW.GLFW_RESIZE_NWSE_CURSOR, GLFW.GLFW_RESIZE_ALL_CURSOR, GLFW.GLFW_HRESIZE_CURSOR, GLFW.GLFW_VRESIZE_CURSOR), + RESIZE_NESW(GLFW.GLFW_RESIZE_NESW_CURSOR, GLFW.GLFW_RESIZE_ALL_CURSOR, GLFW.GLFW_HRESIZE_CURSOR, GLFW.GLFW_VRESIZE_CURSOR); + + private final int[] glfwShapes; + + CursorKind(int... glfwShapes) { + this.glfwShapes = glfwShapes; + } + + int[] glfwShapes() { + return glfwShapes; + } + } + + private static final class X11ThemeCursorSupport { + private static final Map> CURSOR_NAMES = Map.of( + CursorKind.RESIZE_NWSE, List.of("nwse-resize", "size_bdiag", "bd_double_arrow", "top_left_corner", "bottom_right_corner"), + CursorKind.RESIZE_NESW, List.of("nesw-resize", "size_fdiag", "fd_double_arrow", "top_right_corner", "bottom_left_corner") + ); + private static final Map THEME_CURSORS = new EnumMap<>(CursorKind.class); + + private X11ThemeCursorSupport() { + } + + static boolean apply(long glfwWindowHandle, CursorKind kind) { + if (!CURSOR_NAMES.containsKey(kind)) { + return false; + } + try { + Pointer display = displayPointer(); + if (display == null) { + return false; + } + long x11Window = GLFWNativeX11.glfwGetX11Window(glfwWindowHandle); + if (x11Window == 0L) { + return false; + } + long cursor = THEME_CURSORS.computeIfAbsent(kind, ignored -> loadThemeCursor(display, kind)); + if (cursor == 0L) { + return false; + } + X11Library.INSTANCE.XDefineCursor(display, x11Window, cursor); + X11Library.INSTANCE.XFlush(display); + return true; + } catch (Throwable ignored) { + return false; + } + } + + static void reset(long glfwWindowHandle) { + try { + Pointer display = displayPointer(); + if (display == null) { + return; + } + long x11Window = GLFWNativeX11.glfwGetX11Window(glfwWindowHandle); + if (x11Window == 0L) { + return; + } + X11Library.INSTANCE.XUndefineCursor(display, x11Window); + X11Library.INSTANCE.XFlush(display); + } catch (Throwable ignored) { + } + } + + private static long loadThemeCursor(Pointer display, CursorKind kind) { + for (String cursorName : CURSOR_NAMES.getOrDefault(kind, List.of())) { + long handle = XcursorLibrary.INSTANCE.XcursorLibraryLoadCursor(display, cursorName); + if (handle != 0L) { + return handle; + } + } + return 0L; + } + + private static Pointer displayPointer() { + long displayHandle = GLFWNativeX11.glfwGetX11Display(); + return displayHandle == 0L ? null : new Pointer(displayHandle); + } + } + + private interface XcursorLibrary extends Library { + XcursorLibrary INSTANCE = Native.load("Xcursor", XcursorLibrary.class); + + long XcursorLibraryLoadCursor(Pointer display, String name); + } + + private interface X11Library extends Library { + X11Library INSTANCE = Native.load("X11", X11Library.class); + + int XDefineCursor(Pointer display, long window, long cursor); + + int XUndefineCursor(Pointer display, long window); + + int XFlush(Pointer display); + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphEditorBounds.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphEditorBounds.java new file mode 100644 index 0000000..81465e3 --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphEditorBounds.java @@ -0,0 +1,28 @@ +package com.github.squi2rel.mcng.fabric.client; + +public record GraphEditorBounds(int x, int y, int width, int height) { + public GraphEditorBounds { + if (width < 0) { + throw new IllegalArgumentException("width must be non-negative"); + } + if (height < 0) { + throw new IllegalArgumentException("height must be non-negative"); + } + } + + public boolean contains(double screenX, double screenY) { + return screenX >= x && screenX <= x + width && screenY >= y && screenY <= y + height; + } + + public int right() { + return x + width; + } + + public int bottom() { + return y + height; + } + + public GraphEditorBounds inset(int inset) { + return new GraphEditorBounds(x + inset, y + inset, Math.max(0, width - (inset * 2)), Math.max(0, height - (inset * 2))); + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphEditorComponent.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphEditorComponent.java new file mode 100644 index 0000000..9d032ff --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphEditorComponent.java @@ -0,0 +1,423 @@ +package com.github.squi2rel.mcng.fabric.client; + +import com.github.squi2rel.mcng.core.NodePosition; +import com.github.squi2rel.mcng.core.NodeType; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import org.lwjgl.glfw.GLFW; + +public final class GraphEditorComponent { + private static final int TOP_BAR_HEIGHT = 28; + private static final int TOP_BAR_PADDING = 8; + private static final double CONTEXT_MENU_DRAG_THRESHOLD = 4.0D; + + private final GraphEditorSession session; + private final NodePaletteRegistry paletteRegistry; + private final NodeComponentRegistry componentRegistry; + private final GraphCanvasComponent canvas; + private final NodePaletteComponent palette; + private final GraphContextMenuComponent contextMenu; + + private GraphEditorUiConfig uiConfig; + private Font textRenderer; + private GraphEditorBounds bounds = new GraphEditorBounds(0, 0, 0, 0); + private boolean secondaryPointerDown; + private boolean secondaryPointerDragged; + private double secondaryPointerStartX; + private double secondaryPointerStartY; + + public GraphEditorComponent(GraphEditorSession session, NodePaletteRegistry paletteRegistry, GraphEditorUiConfig uiConfig) { + this(session, paletteRegistry, new NodeComponentRegistry(), uiConfig); + } + + public GraphEditorComponent(GraphEditorSession session, NodePaletteRegistry paletteRegistry, NodeComponentRegistry componentRegistry, GraphEditorUiConfig uiConfig) { + this.session = session; + this.paletteRegistry = paletteRegistry; + this.componentRegistry = componentRegistry; + this.uiConfig = uiConfig; + this.canvas = new GraphCanvasComponent(session, new GraphInteractionController(), this::uiConfig, componentRegistry); + this.palette = new NodePaletteComponent( + () -> NodePaletteCatalog.buildSections(session, paletteRegistry), + session::resolvedRegistry, + () -> componentRegistry, + session.portTypes(), + this::uiConfig, + session::i18n, + session::readClipboard, + session::copyToClipboard + ); + this.contextMenu = new GraphContextMenuComponent(this::uiConfig, session::i18n); + } + + public void init(Font textRenderer, GraphEditorBounds bounds) { + this.textRenderer = textRenderer; + this.bounds = bounds; + canvas.init(textRenderer, canvasBounds()); + palette.init(textRenderer, bounds); + } + + public void close() { + canvas.close(); + GraphCursorManager.reset(); + } + + public void setBounds(GraphEditorBounds bounds) { + this.bounds = bounds; + canvas.setBounds(canvasBounds()); + palette.setBounds(bounds); + } + + public GraphEditorSession session() { + return session; + } + + public GraphViewportState viewport() { + return canvas.viewport(); + } + + public GraphEditorUiConfig uiConfig() { + return uiConfig; + } + + public void setUiConfig(GraphEditorUiConfig uiConfig) { + this.uiConfig = uiConfig; + } + + public boolean isPaletteOpen() { + return palette.isOpen(); + } + + public int paletteSidebarRight() { + return palette.sidebarRight(); + } + + public GraphEditorBounds bounds() { + return bounds; + } + + public void render(GuiGraphicsExtractor context, Font textRenderer, int mouseX, int mouseY, float delta) { + this.textRenderer = textRenderer; + canvas.render(context, textRenderer, mouseX, mouseY); + renderTopBar(context); + palette.render(context, textRenderer, mouseX, mouseY, delta); + contextMenu.render(context, textRenderer); + palette.renderDragPreview(context, textRenderer); + updateCursor(mouseX, mouseY); + } + + public boolean mouseClicked(double mouseX, double mouseY, int button) { + if (!bounds.contains(mouseX, mouseY)) { + if (contextMenu.isOpen()) { + contextMenu.close(); + return true; + } + return false; + } + canvas.prepareForClick(mouseX, mouseY, button); + if (contextMenu.isOpen()) { + if (contextMenu.blocksCanvasAt(mouseX, mouseY)) { + GraphContextMenuComponent.MenuAction action = contextMenu.mouseClicked(mouseX, mouseY, button); + if (action != null) { + performContextMenuAction(action); + } + return true; + } + contextMenu.close(); + if (button != 1) { + return true; + } + } + if (!palette.blocksCanvasAt(mouseX, mouseY)) { + palette.blurSearch(); + } + if (button == 0 && clickBreadcrumb(mouseX, mouseY)) { + canvas.viewport().reset(); + return true; + } + if (palette.mouseClicked(mouseX, mouseY, button)) { + return true; + } + if (palette.blocksCanvasAt(mouseX, mouseY)) { + return true; + } + if (!canvas.contains(mouseX, mouseY)) { + return false; + } + if (button == 1) { + if (session.cancelPendingConnection()) { + contextMenu.close(); + return true; + } + canvas.commitInlineEditor(); + if (canvas.portAt(mouseX, mouseY) != null) { + return canvas.mouseClicked(mouseX, mouseY, button); + } + beginSecondaryPointer(mouseX, mouseY); + return true; + } + return canvas.mouseClicked(mouseX, mouseY, button); + } + + public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) { + if (secondaryPointerDown && button == 1) { + if (!secondaryPointerDragged && exceededSecondaryDragThreshold(mouseX, mouseY)) { + secondaryPointerDragged = true; + } + if (secondaryPointerDragged) { + canvas.viewport().pan(deltaX, deltaY); + } + return true; + } + if (contextMenu.isOpen()) { + return true; + } + if (palette.mouseDragged(mouseX, mouseY, button, deltaX, deltaY)) { + return true; + } + if (palette.blocksCanvasAt(mouseX, mouseY)) { + return true; + } + return canvas.contains(mouseX, mouseY) && canvas.mouseDragged(mouseX, mouseY, button, deltaX, deltaY); + } + + public boolean mouseReleased(double mouseX, double mouseY, int button) { + if (secondaryPointerDown && button == 1) { + boolean dragged = secondaryPointerDragged; + clearSecondaryPointer(); + if (!dragged) { + openContextMenuAt(mouseX, mouseY); + } + return true; + } + if (contextMenu.isOpen()) { + return true; + } + NodePaletteInteractionResult result = palette.mouseReleased(mouseX, mouseY, button); + if (result.action() instanceof NodePaletteAction.CreateAtCenter createAtCenter) { + addNodeAtVisibleCenter(createAtCenter.nodeTypeId()); + return true; + } + if (result.action() instanceof NodePaletteAction.CreateAtPointer createAtPointer) { + addNodeAtScreenPosition(createAtPointer.nodeTypeId(), createAtPointer.screenX(), createAtPointer.screenY()); + return true; + } + if (result.handled()) { + return true; + } + return canvas.contains(mouseX, mouseY) && canvas.mouseReleased(mouseX, mouseY, button); + } + + public boolean mouseScrolled(double mouseX, double mouseY, double horizontalAmount, double verticalAmount) { + if (!bounds.contains(mouseX, mouseY)) { + return false; + } + if (contextMenu.isOpen()) { + return true; + } + if (palette.mouseScrolled(mouseX, mouseY, horizontalAmount, verticalAmount)) { + return true; + } + boolean paletteBlocks = palette.blocksCanvasAt(mouseX, mouseY); + if (paletteBlocks) { + return true; + } + return canvas.contains(mouseX, mouseY) && canvas.mouseScrolled(mouseX, mouseY, horizontalAmount, verticalAmount); + } + + public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + if (contextMenu.isOpen() && keyCode == GLFW.GLFW_KEY_ESCAPE) { + contextMenu.close(); + return true; + } + if (palette.isSearchFocused()) { + palette.keyPressed(keyCode, scanCode, modifiers); + return true; + } + if (canvas.isTextEditing()) { + canvas.keyPressed(keyCode, scanCode, modifiers); + return true; + } + if (isUndoShortcut(keyCode, modifiers)) { + return session.undo(); + } + if (isRedoShortcut(keyCode, modifiers)) { + return session.redo(); + } + if (keyCode == GLFW.GLFW_KEY_TAB) { + canvas.commitInlineEditor(); + contextMenu.close(); + palette.toggle(); + return true; + } + if (canvas.keyPressed(keyCode, scanCode, modifiers)) { + return true; + } + return palette.keyPressed(keyCode, scanCode, modifiers); + } + + public boolean charTyped(char chr, int modifiers) { + if (palette.isSearchFocused()) { + palette.charTyped(chr, modifiers); + return true; + } + if (canvas.charTyped(chr, modifiers)) { + return true; + } + return palette.charTyped(chr, modifiers); + } + + private void renderTopBar(GuiGraphicsExtractor context) { + GraphEditorTheme theme = uiConfig.theme(); + EditorStyleRenderer.drawBox(context, bounds.x(), bounds.y(), bounds.width(), TOP_BAR_HEIGHT, theme.panelBackgroundColor(), theme.panelBorderColor(), uiConfig); + renderBreadcrumbs(context, theme); + } + + private void renderBreadcrumbs(GuiGraphicsExtractor context, GraphEditorTheme theme) { + int x = bounds.x() + TOGGLE_AREA_WIDTH(); + int y = bounds.y() + TOP_BAR_PADDING; + var breadcrumbs = session.breadcrumbs(); + for (int index = 0; index < breadcrumbs.size(); index++) { + GraphEditorSession.Breadcrumb breadcrumb = breadcrumbs.get(index); + context.text(textRenderer, breadcrumb.label(), x, y, theme.accentColor(), false); + x += textRenderer.width(breadcrumb.label()); + if (index < breadcrumbs.size() - 1) { + context.text(textRenderer, " / ", x, y, theme.secondaryTextColor(), false); + x += textRenderer.width(" / "); + } + } + } + + private boolean clickBreadcrumb(double mouseX, double mouseY) { + int x = bounds.x() + TOGGLE_AREA_WIDTH(); + int y = bounds.y() + TOP_BAR_PADDING + 2; + for (GraphEditorSession.Breadcrumb breadcrumb : session.breadcrumbs()) { + int width = textRenderer.width(breadcrumb.label()); + if (mouseX >= x && mouseX <= x + width && mouseY >= y - 2 && mouseY <= y + 10) { + return session.exitToBreadcrumb(breadcrumb.definitionId()); + } + x += width + textRenderer.width(" / "); + } + return false; + } + + private void openContextMenuAt(double mouseX, double mouseY) { + NodeWidget node = canvas.nodeAt(mouseX, mouseY); + if (node != null) { + if (!session.isSelected(node.node().id())) { + session.selectNode(node.node().id()); + } + contextMenu.openNodeMenu(mouseX, mouseY, bounds, textRenderer); + } else { + contextMenu.openCanvasMenu(mouseX, mouseY, bounds, textRenderer, session.hasLocalClipboard()); + } + } + + private void performContextMenuAction(GraphContextMenuComponent.MenuAction action) { + switch (action) { + case COPY -> session.copySelectionToLocalClipboard(); + case CUT -> { + if (session.copySelectionToLocalClipboard()) { + session.removeSelectedNodes(); + } + } + case DELETE -> session.removeSelectedNodes(); + case PASTE -> session.pasteLocalClipboard(canvas.viewport().toWorldX(contextMenu.anchorScreenX() - canvas.bounds().x()) - 80, canvas.viewport().toWorldY(contextMenu.anchorScreenY() - canvas.bounds().y()) - 40); + } + } + + private void addNodeAtVisibleCenter(String nodeTypeId) { + GraphEditorBounds canvasBounds = canvasBounds(); + double visibleLeft = palette.isOpen() ? palette.sidebarRight() + 10.0 : canvasBounds.x(); + double centerX = visibleLeft + ((canvasBounds.right() - visibleLeft) / 2.0); + double centerY = canvasBounds.y() + (canvasBounds.height() / 2.0); + addNodeAtScreenPosition(nodeTypeId, centerX, centerY); + } + + private void addNodeAtScreenPosition(String nodeTypeId, double screenX, double screenY) { + NodeType nodeType = session.resolvedRegistry().getOrThrow(nodeTypeId); + NodePosition position = placementPosition(canvas.viewport(), canvas.bounds(), nodeType, uiConfig, componentRegistry, screenX, screenY); + session.addNode(nodeTypeId, position.x(), position.y()); + } + + private static boolean isUndoShortcut(int keyCode, int modifiers) { + return keyCode == GLFW.GLFW_KEY_Z + && (modifiers & GLFW.GLFW_MOD_CONTROL) != 0 + && (modifiers & GLFW.GLFW_MOD_SHIFT) == 0; + } + + private static boolean isRedoShortcut(int keyCode, int modifiers) { + return keyCode == GLFW.GLFW_KEY_Z + && (modifiers & GLFW.GLFW_MOD_CONTROL) != 0 + && (modifiers & GLFW.GLFW_MOD_SHIFT) != 0; + } + + static NodePosition placementPosition( + GraphViewportState viewport, + GraphEditorBounds canvasBounds, + NodeType nodeType, + double screenX, + double screenY + ) { + return placementPosition(viewport, canvasBounds, nodeType, GraphEditorUiConfig.defaultConfig(), new NodeComponentRegistry(), screenX, screenY); + } + + static NodePosition placementPosition( + GraphViewportState viewport, + GraphEditorBounds canvasBounds, + NodeType nodeType, + GraphEditorUiConfig uiConfig, + NodeComponentRegistry componentRegistry, + double screenX, + double screenY + ) { + NodeComponentDefinition definition = componentRegistry.find(nodeType.id()).orElse(null); + NodeBodyComponent component = definition == null ? null : definition.factory().create(); + ResizePolicy resizePolicy = definition == null ? ResizePolicy.none() : definition.resizePolicy(); + NodeWidget preview = NodeWidget.preview(nodeType, uiConfig, component, resizePolicy, (int) Math.round(screenX), (int) Math.round(screenY)); + double worldX = viewport.toWorldX(preview.x() - canvasBounds.x()); + double worldY = viewport.toWorldY(preview.y() - canvasBounds.y()); + return new NodePosition(worldX, worldY); + } + + private void beginSecondaryPointer(double mouseX, double mouseY) { + secondaryPointerDown = true; + secondaryPointerDragged = false; + secondaryPointerStartX = mouseX; + secondaryPointerStartY = mouseY; + } + + private void clearSecondaryPointer() { + secondaryPointerDown = false; + secondaryPointerDragged = false; + } + + private void updateCursor(double mouseX, double mouseY) { + if (contextMenu.isOpen() || !bounds.contains(mouseX, mouseY)) { + GraphCursorManager.reset(); + return; + } + if (palette.blocksCanvasAt(mouseX, mouseY)) { + GraphCursorManager.apply(palette.cursorKindAt(mouseX, mouseY)); + return; + } + if (canvas.contains(mouseX, mouseY)) { + GraphCursorManager.apply(canvas.cursorKindAt(mouseX, mouseY)); + return; + } + GraphCursorManager.reset(); + } + + private boolean exceededSecondaryDragThreshold(double mouseX, double mouseY) { + double dx = mouseX - secondaryPointerStartX; + double dy = mouseY - secondaryPointerStartY; + return (dx * dx) + (dy * dy) >= (CONTEXT_MENU_DRAG_THRESHOLD * CONTEXT_MENU_DRAG_THRESHOLD); + } + + private GraphEditorBounds canvasBounds() { + return new GraphEditorBounds(bounds.x(), bounds.y() + TOP_BAR_HEIGHT, bounds.width(), Math.max(0, bounds.height() - TOP_BAR_HEIGHT)); + } + + private int TOGGLE_AREA_WIDTH() { + return 112; + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphEditorHost.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphEditorHost.java new file mode 100644 index 0000000..1498b68 --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphEditorHost.java @@ -0,0 +1,27 @@ +package com.github.squi2rel.mcng.fabric.client; + +import com.github.squi2rel.mcng.core.GraphDocument; + +import java.util.Optional; + +public interface GraphEditorHost { + void onDocumentChanged(GraphDocument document); + + void copyToClipboard(String value); + + String readClipboard(); + + void showMessage(String message); + + default GraphEditorI18n i18n() { + return GraphEditorI18n.identity(); + } + + default boolean supportsFileDialogs() { + return false; + } + + default Optional chooseFile(GraphFileDialogRequest request) { + return Optional.empty(); + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphEditorI18n.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphEditorI18n.java new file mode 100644 index 0000000..82e2361 --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphEditorI18n.java @@ -0,0 +1,25 @@ +package com.github.squi2rel.mcng.fabric.client; + +import java.util.IllegalFormatException; +import java.util.Locale; + +@FunctionalInterface +public interface GraphEditorI18n { + String translate(String key, String fallback, Object... args); + + static GraphEditorI18n identity() { + return (key, fallback, args) -> formatFallback(fallback, key, args); + } + + static String formatFallback(String fallback, String key, Object... args) { + String template = fallback != null && !fallback.isBlank() ? fallback : key; + if (args == null || args.length == 0) { + return template; + } + try { + return String.format(Locale.ROOT, template, args); + } catch (IllegalFormatException exception) { + return template; + } + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphEditorSession.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphEditorSession.java new file mode 100644 index 0000000..5517599 --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphEditorSession.java @@ -0,0 +1,2451 @@ +package com.github.squi2rel.mcng.fabric.client; + +import com.github.squi2rel.mcng.core.DocumentNodeDefinition; +import com.github.squi2rel.mcng.core.DocumentNodeDefinitionKind; +import com.github.squi2rel.mcng.core.DocumentNodeTypes; +import com.github.squi2rel.mcng.core.CompiledGraphDocument; +import com.github.squi2rel.mcng.core.DynamicPortTypeResolverContext; +import com.github.squi2rel.mcng.core.EdgeDefinition; +import com.github.squi2rel.mcng.core.ExecutionPosition; +import com.github.squi2rel.mcng.core.ExecutionResult; +import com.github.squi2rel.mcng.core.ExecutionSessionStatus; +import com.github.squi2rel.mcng.core.ExecutionSnapshot; +import com.github.squi2rel.mcng.core.GraphDefinition; +import com.github.squi2rel.mcng.core.GraphDocument; +import com.github.squi2rel.mcng.core.GraphError; +import com.github.squi2rel.mcng.core.GraphExecutor; +import com.github.squi2rel.mcng.core.GraphExecutionSession; +import com.github.squi2rel.mcng.core.GraphJsonCodec; +import com.github.squi2rel.mcng.core.GraphLayout; +import com.github.squi2rel.mcng.core.GraphPortTypeResolver; +import com.github.squi2rel.mcng.core.GraphScope; +import com.github.squi2rel.mcng.core.GraphVariableDefinition; +import com.github.squi2rel.mcng.core.MCNGPortTypes; +import com.github.squi2rel.mcng.core.NodeConfigValues; +import com.github.squi2rel.mcng.core.NodeId; +import com.github.squi2rel.mcng.core.NodeInstance; +import com.github.squi2rel.mcng.core.NodeKind; +import com.github.squi2rel.mcng.core.NodePosition; +import com.github.squi2rel.mcng.core.NodeSize; +import com.github.squi2rel.mcng.core.NodeType; +import com.github.squi2rel.mcng.core.NodeTypeRegistry; +import com.github.squi2rel.mcng.core.NumericTypes; +import com.github.squi2rel.mcng.core.PortChannel; +import com.github.squi2rel.mcng.core.PortDefinition; +import com.github.squi2rel.mcng.core.PortDirection; +import com.github.squi2rel.mcng.core.PortId; +import com.github.squi2rel.mcng.core.PortType; +import com.github.squi2rel.mcng.core.PortTypeRegistry; +import com.github.squi2rel.mcng.core.ResolvedPortType; +import com.github.squi2rel.mcng.core.SubgraphDefinition; +import com.github.squi2rel.mcng.core.builtin.BuiltinNodeTypes; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +import java.util.ArrayList; +import java.util.ArrayDeque; +import java.util.Collection; +import java.util.Comparator; +import java.util.Deque; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +public final class GraphEditorSession { + private static final String REROUTE_ORIENTATION_KEY = "__rerouteOrientation"; + private static final String LEGACY_REROUTE_FLIPPED_KEY = "__rerouteFlipped"; + private static final String LEGACY_REROUTE_VERTICAL_KEY = "__rerouteVertical"; + private static final String FLAT_NODE_PREFIX = "$flat$"; + private static final NodeSize DEFAULT_SELECTION_NODE_SIZE = new NodeSize(196, 96); + private static final int MAX_HISTORY_ENTRIES = 128; + + private final NodeTypeRegistry baseRegistry; + private final PortTypeRegistry portTypes; + private final GraphJsonCodec codec; + private final GraphEditorHost host; + private final MutableGraphState root = new MutableGraphState(); + private final Map definitions = new LinkedHashMap<>(); + private final Map definitionParents = new LinkedHashMap<>(); + private final Map rootVariables = new LinkedHashMap<>(); + private final List contextPath = new ArrayList<>(); + private final List contextNodePath = new ArrayList<>(); + private final List debugMessages = new ArrayList<>(); + private final Set selectedNodeIds = new LinkedHashSet<>(); + private final Deque undoStack = new ArrayDeque<>(); + private final Deque redoStack = new ArrayDeque<>(); + + private LocalClipboard localClipboard; + private List lastErrors = List.of(); + private PendingConnection pendingConnection; + private NodeId primarySelectedNodeId; + private long triggerCounter = 1L; + private NodeTypeRegistry resolvedRegistry; + private CompiledGraphDocument compiledDocument; + private GraphExecutionSession runningExecution; + private ExecutionSnapshot lastExecutionSnapshot = emptySnapshot(); + private Set executingVisibleNodeIds = Set.of(); + private Set executingVisibleRerouteNodeIds = Set.of(); + private int compositeEditDepth; + private EditorSnapshot compositeEditStart; + private EditorSnapshot compositeEditLatest; + private boolean compositeEditChanged; + + public GraphEditorSession(NodeTypeRegistry registry, PortTypeRegistry portTypes, GraphJsonCodec codec, GraphDocument document, GraphEditorHost host) { + this.baseRegistry = Objects.requireNonNull(registry, "registry"); + this.portTypes = Objects.requireNonNull(portTypes, "portTypes"); + this.codec = Objects.requireNonNull(codec, "codec"); + this.host = Objects.requireNonNull(host, "host"); + load(document); + } + + public void load(GraphDocument document) { + Objects.requireNonNull(document, "document"); + finishOpenCompositeEdit(); + loadDocumentState(document); + resetForLoadedDocument(true); + clearHistoryState(); + refreshRuntime(document); + } + + public boolean replaceDocument(GraphDocument document) { + Objects.requireNonNull(document, "document"); + finishOpenCompositeEdit(); + EditorSnapshot before = captureSnapshot(); + loadDocumentState(document); + resetForLoadedDocument(false); + GraphDocument after = this.document(); + if (before.matches(after, contextPath, contextNodePath, selectedNodeIds, primarySelectedNodeId)) { + return false; + } + pushHistory(undoStack, new HistoryEntry(before, captureSnapshot(after))); + redoStack.clear(); + persist(after); + return true; + } + + public boolean canUndo() { + return !undoStack.isEmpty(); + } + + public boolean canRedo() { + return !redoStack.isEmpty(); + } + + public boolean undo() { + finishOpenCompositeEdit(); + HistoryEntry entry = undoStack.pollLast(); + if (entry == null) { + return false; + } + pushHistory(redoStack, entry); + applySnapshot(entry.before()); + return true; + } + + public boolean redo() { + finishOpenCompositeEdit(); + HistoryEntry entry = redoStack.pollLast(); + if (entry == null) { + return false; + } + pushHistory(undoStack, entry); + applySnapshot(entry.after()); + return true; + } + + public void beginCompositeEdit() { + if (compositeEditDepth == 0) { + compositeEditStart = captureSnapshot(); + compositeEditLatest = compositeEditStart; + compositeEditChanged = false; + } + compositeEditDepth++; + } + + public void endCompositeEdit() { + if (compositeEditDepth <= 0) { + return; + } + compositeEditDepth--; + if (compositeEditDepth > 0) { + return; + } + if (compositeEditChanged && compositeEditStart != null) { + pushHistory(undoStack, new HistoryEntry(compositeEditStart, compositeEditLatest == null ? compositeEditStart : compositeEditLatest)); + } + compositeEditStart = null; + compositeEditLatest = null; + compositeEditChanged = false; + } + + public GraphDocument document() { + List customDefinitions = definitions.values().stream() + .filter(definition -> definition.kind == DocumentNodeDefinitionKind.CUSTOM_NODE) + .filter(definition -> definitionParents.get(definition.id) == null) + .map(this::toCustomDefinition) + .toList(); + GraphScope rootScope = new GraphScope( + root.graph(), + root.layout(), + new ArrayList<>(rootVariables.values()), + subgraphsForParent(null) + ); + return GraphDocument.of(rootScope, customDefinitions); + } + + public List variables() { + return List.copyOf(currentVariableState().values()); + } + + public GraphVariableDefinition createVariable() { + String id = nextVariableId(); + GraphVariableDefinition variable = new GraphVariableDefinition(id, id, MCNGPortTypes.INT.id(), NodeConfigValues.intValue(0)); + EditorSnapshot before = captureSnapshot(); + currentVariableState().put(id, variable); + finishMutation(before); + return variable; + } + + public boolean cycleVariableType(String variableId) { + Map variables = currentVariableState(); + GraphVariableDefinition variable = variables.get(variableId); + if (variable == null) { + return false; + } + List order = List.of(MCNGPortTypes.INT.id(), MCNGPortTypes.LONG.id(), MCNGPortTypes.DOUBLE.id(), MCNGPortTypes.STRING.id(), MCNGPortTypes.BOOLEAN.id(), MCNGPortTypes.ANY.id()); + int currentIndex = order.indexOf(variable.typeId()); + String nextType = order.get((currentIndex + 1 + order.size()) % order.size()); + EditorSnapshot before = captureSnapshot(); + variables.put(variableId, new GraphVariableDefinition(variable.id(), variable.displayName(), nextType, defaultVariableValue(nextType))); + return finishMutation(before); + } + + public boolean removeVariable(String variableId) { + Map variables = currentVariableState(); + if (!variables.containsKey(variableId)) { + return false; + } + EditorSnapshot before = captureSnapshot(); + variables.remove(variableId); + return finishMutation(before); + } + + public List nodes() { + return currentState().nodes.values().stream().sorted(Comparator.comparing(node -> node.id().value())).toList(); + } + + public List edges() { + return List.copyOf(currentState().edges); + } + + public Map positions() { + return Map.copyOf(currentState().positions); + } + + public Map sizes() { + return Map.copyOf(currentState().sizes); + } + + public Optional selectedNodeId() { + return Optional.ofNullable(primarySelectedNodeId); + } + + public Set selectedNodeIds() { + return Set.copyOf(selectedNodeIds); + } + + public PendingConnection pendingConnection() { + return pendingConnection; + } + + public List debugMessages() { + return List.copyOf(debugMessages); + } + + public List lastErrors() { + return lastErrors; + } + + public boolean hasError(NodeId nodeId) { + if (lastErrors.stream().anyMatch(error -> nodeId.equals(error.nodeId()))) { + return true; + } + String customDefinitionId = currentCustomDefinitionId(); + List planContext = currentPlanContextPath(); + for (GraphError error : lastErrors) { + List path = customDefinitionId == null + ? compiledDocument.scopePathForNode(error.nodeId()) + : compiledDocument.scopePathForDefinitionNode(customDefinitionId, error.nodeId()); + if (path.equals(planContext) && nodeId.equals(visibleNodeIdFromRuntimeNode(error.nodeId(), planContext))) { + return true; + } + if (path.size() > planContext.size() && path.subList(0, planContext.size()).equals(planContext)) { + NodeId containerNodeId = containerNodeIdForSubgraph(path.get(planContext.size())); + if (nodeId.equals(containerNodeId)) { + return true; + } + } + } + return false; + } + + public boolean isExecuting(NodeId nodeId) { + return executingVisibleNodeIds.contains(nodeId) || executingVisibleRerouteNodeIds.contains(nodeId); + } + + public boolean isExecutionRunning() { + return runningExecution != null && lastExecutionSnapshot.status() == ExecutionSessionStatus.RUNNING; + } + + public NodeType nodeType(NodeInstance node) { + return runtimeRegistry().getOrThrow(node.typeId()); + } + + public NodeInstance node(NodeId nodeId) { + return requireNode(nodeId); + } + + public List> availableNodeTypes() { + return runtimeRegistry().all().stream() + .sorted(Comparator.comparing(NodeType::displayName)) + .toList(); + } + + public NodeTypeRegistry resolvedRegistry() { + return runtimeRegistry(); + } + + public PortTypeRegistry portTypes() { + return portTypes; + } + + public GraphEditorI18n i18n() { + return host.i18n(); + } + + public String translate(String key, String fallback, Object... args) { + return i18n().translate(key, fallback, args); + } + + public String channelLabel(PortChannel channel) { + if (channel == null) { + return translate("mcng.ui.common.channel.unknown", "Unknown"); + } + return switch (channel) { + case DATA -> translate("mcng.ui.common.channel.data", "Data"); + case CONTROL -> translate("mcng.ui.common.channel.control", "Control"); + }; + } + + public String portTypeLabel(PortType type) { + return GraphEditorTranslations.shortPortTypeLabel(i18n(), type == null ? null : type.id()); + } + + public PortType effectivePortType(NodeId nodeId, PortId portId, PortDirection direction) { + try { + return resolvePortType(currentDepth(), nodeId, portId, direction).effectiveType(); + } catch (IllegalArgumentException exception) { + return requirePortDefinition(graphAtDepth(currentDepth()), nodeId, portId, direction).type(); + } + } + + public PortChannel effectivePortChannel(NodeId nodeId, PortId portId, PortDirection direction) { + try { + return resolvePortChannel(currentDepth(), nodeId, portId, direction); + } catch (IllegalArgumentException exception) { + return requirePortDefinition(graphAtDepth(currentDepth()), nodeId, portId, direction).channel(); + } + } + + public boolean isSelected(NodeId nodeId) { + return selectedNodeIds.contains(nodeId); + } + + public void selectNode(NodeId nodeId) { + selectedNodeIds.clear(); + if (nodeId != null) { + selectedNodeIds.add(nodeId); + } + primarySelectedNodeId = nodeId; + } + + public void toggleNodeSelection(NodeId nodeId) { + if (selectedNodeIds.contains(nodeId)) { + selectedNodeIds.remove(nodeId); + if (Objects.equals(primarySelectedNodeId, nodeId)) { + primarySelectedNodeId = selectedNodeIds.stream().findFirst().orElse(null); + } + return; + } + selectedNodeIds.add(nodeId); + primarySelectedNodeId = nodeId; + } + + public void selectNodes(Collection nodeIds, boolean additive) { + if (!additive) { + selectedNodeIds.clear(); + } + for (NodeId nodeId : nodeIds) { + if (currentState().nodes.containsKey(nodeId)) { + selectedNodeIds.add(nodeId); + primarySelectedNodeId = nodeId; + } + } + if (selectedNodeIds.isEmpty()) { + primarySelectedNodeId = null; + } + } + + public void clearSelection() { + selectedNodeIds.clear(); + primarySelectedNodeId = null; + } + + public void moveNode(NodeId nodeId, double x, double y) { + EditorSnapshot before = captureSnapshot(); + currentState().positions.put(nodeId, new NodePosition(x, y)); + finishMutation(before); + } + + public void moveNodes(Map updatedPositions) { + EditorSnapshot before = captureSnapshot(); + updatedPositions.forEach((nodeId, position) -> { + if (currentState().nodes.containsKey(nodeId)) { + currentState().positions.put(nodeId, position); + } + }); + finishMutation(before); + } + + public void resizeNode(NodeId nodeId, NodePosition position, NodeSize size) { + if (!currentState().nodes.containsKey(nodeId)) { + return; + } + EditorSnapshot before = captureSnapshot(); + currentState().positions.put(nodeId, position); + currentState().sizes.put(nodeId, size); + finishMutation(before); + } + + public void resizeReroute(NodeId nodeId, NodePosition position, NodeSize size, RerouteOrientation orientation) { + NodeInstance node = currentState().nodes.get(nodeId); + if (!isReroute(nodeId, node)) { + return; + } + EditorSnapshot before = captureSnapshot(); + currentState().positions.put(nodeId, position); + currentState().sizes.put(nodeId, size); + setRerouteOrientation(nodeId, orientation); + finishMutation(before); + } + + public void addNode(String nodeTypeId, double x, double y) { + NodeType nodeType = runtimeRegistry().getOrThrow(nodeTypeId); + NodeId nodeId = NodeId.random(); + EditorSnapshot before = captureSnapshot(); + currentState().nodes.put(nodeId, createNode(nodeId, nodeType)); + currentState().positions.put(nodeId, new NodePosition(x, y)); + selectNode(nodeId); + finishMutation(before); + } + + public void removeSelectedNode() { + removeSelectedNodes(); + } + + public void removeSelectedNodes() { + if (selectedNodeIds.isEmpty()) { + return; + } + + EditorSnapshot before = captureSnapshot(); + Set removed = new LinkedHashSet<>(selectedNodeIds); + removed.forEach(nodeId -> { + currentState().nodes.remove(nodeId); + currentState().positions.remove(nodeId); + currentState().sizes.remove(nodeId); + }); + currentState().edges.removeIf(edge -> removed.contains(edge.fromNodeId()) || removed.contains(edge.toNodeId())); + pendingConnection = null; + clearSelection(); + finishMutation(before); + } + + public boolean cancelPendingConnection() { + if (pendingConnection == null) { + return false; + } + pendingConnection = null; + return true; + } + + public boolean toggleConnectionCandidate(NodeId nodeId, PortId portId, PortDirection direction) { + return toggleConnectionCandidate(nodeId, portId, direction, direction == PortDirection.OUTPUT); + } + + public boolean toggleConnectionCandidate(NodeId nodeId, PortId portId, PortDirection direction, boolean rightSide) { + return toggleConnectionCandidate(nodeId, portId, direction, rightSide ? NodeWidget.PortSide.RIGHT : NodeWidget.PortSide.LEFT); + } + + public boolean toggleConnectionCandidate(NodeId nodeId, PortId portId, PortDirection direction, NodeWidget.PortSide side) { + if (pendingConnection != null && pendingConnection.nodeId().equals(nodeId) && pendingConnection.portId().equals(portId) && pendingConnection.direction() == direction) { + pendingConnection = null; + return true; + } + + EditorSnapshot before = captureSnapshot(); + boolean rerouteOrientationChanged = false; + PortDirection effectiveDirection = direction; + if (pendingConnection != null && isReroute(nodeId, currentState().nodes.get(nodeId))) { + effectiveDirection = opposite(pendingConnection.direction()); + rerouteOrientationChanged = setRerouteOrientation(nodeId, RerouteOrientation.fromPortSide(side, effectiveDirection)); + } + if (pendingConnection == null) { + pendingConnection = new PendingConnection(nodeId, portId, effectiveDirection); + return true; + } + + PendingConnection previous = pendingConnection; + pendingConnection = null; + boolean connected = connectInternal(previous, new PendingConnection(nodeId, portId, effectiveDirection)); + if (connected || rerouteOrientationChanged) { + finishMutation(before); + } + return connected; + } + + public void clearPortConnections(NodeId nodeId, PortId portId, PortDirection direction) { + EditorSnapshot before = captureSnapshot(); + if (direction == PortDirection.INPUT) { + currentState().edges.removeIf(edge -> edge.toNodeId().equals(nodeId) && edge.toPortId().equals(portId)); + } else { + currentState().edges.removeIf(edge -> edge.fromNodeId().equals(nodeId) && edge.fromPortId().equals(portId)); + } + finishMutation(before); + } + + public boolean hasIncomingConnection(NodeId nodeId, PortId portId) { + return currentState().edges.stream().anyMatch(edge -> edge.toNodeId().equals(nodeId) && edge.toPortId().equals(portId)); + } + + boolean isRerouteFlipped(NodeId nodeId) { + return rerouteOrientation(nodeId).inputSide().positiveAxis(); + } + + boolean isRerouteVertical(NodeId nodeId) { + return rerouteOrientation(nodeId).vertical(); + } + + RerouteOrientation rerouteOrientation(NodeId nodeId) { + NodeInstance node = currentState().nodes.get(nodeId); + if (!isReroute(nodeId, node)) { + return RerouteOrientation.LEFT_TO_RIGHT; + } + RerouteOrientation explicit = explicitRerouteOrientation(node.config()); + return explicit != null ? explicit : legacyRerouteOrientation(nodeId, node.config()); + } + + public void updateInlineInput(NodeId nodeId, PortId portId, JsonElement value) { + NodeInstance node = requireNode(nodeId); + EditorSnapshot before = captureSnapshot(); + currentState().nodes.put(nodeId, new NodeInstance(node.id(), node.typeId(), NodeConfigValues.copyWithInlineInput(node.config(), portId, value))); + finishMutation(before); + } + + public void updateControlValue(NodeId nodeId, String key, JsonElement value) { + NodeInstance node = requireNode(nodeId); + EditorSnapshot before = captureSnapshot(); + if ((DocumentNodeTypes.isDefinitionType(node.typeId()) || DocumentNodeTypes.isSubgraphType(node.typeId())) + && DocumentNodeTypes.DEFINITION_NAME_CONTROL_KEY.equals(key)) { + String definitionId = DocumentNodeTypes.isDefinitionType(node.typeId()) + ? DocumentNodeTypes.definitionIdFromTypeId(node.typeId()) + : DocumentNodeTypes.subgraphIdFromTypeId(node.typeId()); + DefinitionState definition = definitions.get(definitionId); + if (definition != null) { + definition.displayName = value.getAsString(); + finishMutation(before); + } + return; + } + currentState().nodes.put(nodeId, new NodeInstance(node.id(), node.typeId(), NodeConfigValues.copyWithControlValue(node.config(), key, value))); + pruneInvalidNodePorts(nodeId); + finishMutation(before); + } + + public String exportJson() { + String json = codec.toJson(document()); + host.copyToClipboard(json); + showTranslatedMessage("mcng.ui.message.graph_json_copied", "Copied graph JSON to clipboard"); + return json; + } + + public boolean importFromClipboard() { + String contents = host.readClipboard(); + if (contents == null || contents.isBlank()) { + showTranslatedMessage("mcng.ui.message.clipboard_empty", "Clipboard is empty"); + return false; + } + + try { + GraphDocument parsed = codec.fromJson(contents); + replaceDocument(parsed); + showTranslatedMessage("mcng.ui.message.graph_json_imported", "Imported graph JSON from clipboard"); + return true; + } catch (RuntimeException exception) { + showTranslatedMessage("mcng.ui.message.graph_json_import_failed", "Failed to import JSON: %s", exception.getMessage()); + return false; + } + } + + public ExecutionResult executeGraph() { + cancelExecutionSilently(); + debugMessages.clear(); + runningExecution = compiledDocument.startExecution(new RecordingExecutionContext()); + lastExecutionSnapshot = runningExecution.snapshot(); + lastErrors = lastExecutionSnapshot.errors(); + refreshExecutionHighlights(); + if (lastExecutionSnapshot.status() == ExecutionSessionStatus.RUNNING) { + showTranslatedMessage("mcng.ui.message.execution_started", "Execution started"); + } else { + runningExecution = null; + if (lastErrors.isEmpty()) { + showTranslatedMessage("mcng.ui.message.execution_completed", "Executed dataflow graph"); + } else { + showTranslatedMessage("mcng.ui.message.execution_errors", "Execution produced %s error(s)", lastErrors.size()); + } + } + return lastExecutionSnapshot.toExecutionResult(); + } + + public ExecutionResult triggerSelectedEvent() { + NodeId source = primarySelectedNodeId != null && runtimeRegistry().getOrThrow(currentState().nodes.get(primarySelectedNodeId).typeId()).kind() == NodeKind.EVENT_SOURCE + ? primarySelectedNodeId + : firstVisibleEventSource(); + + if (source == null) { + showTranslatedMessage("mcng.ui.message.no_event_source", "No event source node is available"); + lastErrors = List.of(); + return new ExecutionResult(Map.of(), Map.of(), List.of(), List.of()); + } + + cancelExecutionSilently(); + debugMessages.clear(); + runningExecution = compiledDocument.startEventExecution(source, (double) triggerCounter++, new RecordingExecutionContext()); + lastExecutionSnapshot = runningExecution.snapshot(); + lastErrors = lastExecutionSnapshot.errors(); + refreshExecutionHighlights(); + if (lastExecutionSnapshot.status() == ExecutionSessionStatus.RUNNING) { + showTranslatedMessage("mcng.ui.message.trigger_started", "Triggered event execution"); + } else { + runningExecution = null; + if (lastErrors.isEmpty()) { + showTranslatedMessage("mcng.ui.message.trigger_completed", "Triggered event graph"); + } else { + showTranslatedMessage("mcng.ui.message.trigger_errors", "Trigger produced %s error(s)", lastErrors.size()); + } + } + return lastExecutionSnapshot.toExecutionResult(); + } + + private NodeId firstVisibleEventSource() { + return compiledDocument.rootEventSourceNodeIds().stream() + .filter(nodeId -> { + List path = compiledDocument.scopePathForNode(nodeId); + List context = currentPlanContextPath(); + return path.size() >= context.size() && path.subList(0, context.size()).equals(context); + }) + .findFirst() + .orElse(null); + } + + public ExecutionSnapshot tickExecution(int maxNodeExecutions) { + if (runningExecution == null) { + return lastExecutionSnapshot; + } + lastExecutionSnapshot = runningExecution.step(maxNodeExecutions); + lastErrors = lastExecutionSnapshot.errors(); + refreshExecutionHighlights(); + if (lastExecutionSnapshot.status() != ExecutionSessionStatus.RUNNING) { + runningExecution = null; + if (lastExecutionSnapshot.cancelled()) { + showTranslatedMessage("mcng.ui.message.execution_cancelled", "Execution cancelled"); + } else if (lastErrors.isEmpty()) { + showTranslatedMessage("mcng.ui.message.execution_finished", "Execution finished"); + } else { + showTranslatedMessage("mcng.ui.message.execution_errors", "Execution produced %s error(s)", lastErrors.size()); + } + } + return lastExecutionSnapshot; + } + + public boolean cancelExecution() { + if (runningExecution == null) { + return false; + } + runningExecution.cancel(); + lastExecutionSnapshot = runningExecution.snapshot(); + lastErrors = lastExecutionSnapshot.errors(); + runningExecution = null; + refreshExecutionHighlights(); + showTranslatedMessage("mcng.ui.message.execution_cancelled", "Execution cancelled"); + return true; + } + + public void showMessage(String message) { + host.showMessage(message); + } + + private void showTranslatedMessage(String key, String fallback, Object... args) { + host.showMessage(translate(key, fallback, args)); + } + + public boolean hasLocalClipboard() { + return localClipboard != null; + } + + public boolean copySelectionToLocalClipboard() { + if (selectedNodeIds.isEmpty()) { + showTranslatedMessage("mcng.ui.message.select_node", "Select at least one node"); + return false; + } + + MutableGraphState workspace = currentState(); + Set selection = new LinkedHashSet<>(selectedNodeIds); + Bounds bounds = selectionBounds(selection, workspace.positions, workspace.sizes); + + List nodes = selection.stream() + .map(workspace.nodes::get) + .filter(Objects::nonNull) + .map(this::copyNodeInstance) + .toList(); + List edges = workspace.edges.stream() + .filter(edge -> selection.contains(edge.fromNodeId()) && selection.contains(edge.toNodeId())) + .map(this::copyEdgeDefinition) + .toList(); + Map positions = new LinkedHashMap<>(); + Map sizes = new LinkedHashMap<>(); + for (NodeId nodeId : selection) { + NodePosition position = workspace.positions.getOrDefault(nodeId, new NodePosition(0, 0)); + positions.put(nodeId, new NodePosition(position.x() - bounds.minX(), position.y() - bounds.minY())); + NodeSize size = workspace.sizes.get(nodeId); + if (size != null) { + sizes.put(nodeId, size); + } + } + + Map definitionsBySourceId = new LinkedHashMap<>(); + for (NodeInstance node : nodes) { + collectSubgraphDefinitions(node.typeId(), definitionsBySourceId, new LinkedHashSet<>()); + } + + localClipboard = new LocalClipboard( + new GraphDefinition(nodes, edges), + new GraphLayout(positions, sizes), + new ArrayList<>(definitionsBySourceId.values()) + ); + showTranslatedMessage("mcng.ui.message.local_clipboard_copied", "Copied %s node(s) to local clipboard", nodes.size()); + return true; + } + + public boolean pasteLocalClipboard(double x, double y) { + if (localClipboard == null) { + showTranslatedMessage("mcng.ui.message.local_clipboard_empty", "Local clipboard is empty"); + return false; + } + if (!isInsideDefinition() && localClipboardContainsHelpers()) { + showTranslatedMessage("mcng.ui.message.paste_helpers_requires_definition", "Graph Input/Output can only be pasted inside definitions"); + return false; + } + + EditorSnapshot before = captureSnapshot(); + Map definitionIdMap = new LinkedHashMap<>(); + for (ClipboardDefinition definition : localClipboard.definitions()) { + definitionIdMap.put(definition.sourceId(), NodeId.random().value()); + collectClipboardSubgraphIds(definition.scope(), definitionIdMap); + } + for (ClipboardDefinition definition : localClipboard.definitions()) { + String newDefinitionId = definitionIdMap.get(definition.sourceId()); + loadSubgraphs( + List.of(new SubgraphDefinition( + newDefinitionId, + definition.displayName(), + rewriteScope(definition.scope(), definitionIdMap) + )), + currentParentRef() + ); + } + + MutableGraphState workspace = currentState(); + Map nodeIdMap = new LinkedHashMap<>(); + List pastedNodeIds = new ArrayList<>(); + + for (NodeInstance node : localClipboard.graph().nodes()) { + NodeId newNodeId = NodeId.random(); + nodeIdMap.put(node.id(), newNodeId); + workspace.nodes.put(newNodeId, rewriteNode(node, newNodeId, definitionIdMap)); + NodePosition offset = localClipboard.layout().nodePositions().getOrDefault(node.id(), new NodePosition(0, 0)); + NodePosition pastedPosition = new NodePosition(x + offset.x(), y + offset.y()); + workspace.positions.put(newNodeId, pastedPosition); + NodeSize size = localClipboard.layout().nodeSizes().get(node.id()); + if (size != null) { + workspace.sizes.put(newNodeId, size); + } + pastedNodeIds.add(newNodeId); + } + + for (EdgeDefinition edge : localClipboard.graph().edges()) { + NodeId fromNodeId = nodeIdMap.get(edge.fromNodeId()); + NodeId toNodeId = nodeIdMap.get(edge.toNodeId()); + if (fromNodeId != null && toNodeId != null) { + workspace.edges.add(new EdgeDefinition(fromNodeId, edge.fromPortId(), toNodeId, edge.toPortId())); + } + } + + selectNodes(pastedNodeIds, false); + if (!pastedNodeIds.isEmpty()) { + primarySelectedNodeId = pastedNodeIds.getLast(); + } + finishMutation(before); + showTranslatedMessage("mcng.ui.message.local_clipboard_pasted", "Pasted %s node(s)", pastedNodeIds.size()); + return true; + } + + public void copyToClipboard(String value) { + host.copyToClipboard(value); + } + + public boolean supportsFileDialogs() { + return host.supportsFileDialogs(); + } + + public Optional chooseFile(GraphFileDialogRequest request) { + Objects.requireNonNull(request, "request"); + return host.chooseFile(request); + } + + public void updateNodeConfig(NodeId nodeId, JsonObject config) { + Objects.requireNonNull(config, "config"); + NodeInstance node = requireNode(nodeId); + EditorSnapshot before = captureSnapshot(); + currentState().nodes.put(nodeId, new NodeInstance(node.id(), node.typeId(), config)); + finishMutation(before); + } + + public String readClipboard() { + return host.readClipboard(); + } + + public List breadcrumbs() { + List breadcrumbs = new ArrayList<>(); + breadcrumbs.add(new Breadcrumb(translate("mcng.ui.breadcrumb.root", "Root"), null)); + for (String definitionId : contextPath) { + DefinitionState definition = definitions.get(definitionId); + if (definition != null) { + breadcrumbs.add(new Breadcrumb(definition.displayName, definitionId)); + } + } + return breadcrumbs; + } + + public boolean isInsideDefinition() { + return !contextPath.isEmpty(); + } + + public String currentDefinitionId() { + return contextPath.isEmpty() ? null : contextPath.getLast(); + } + + public Optional currentDefinitionKind() { + DefinitionState definition = currentDefinition(); + return definition == null ? Optional.empty() : Optional.of(definition.kind); + } + + public boolean canEnterDefinition(NodeId nodeId) { + NodeInstance node = currentState().nodes.get(nodeId); + return node != null && (DocumentNodeTypes.isDefinitionType(node.typeId()) || DocumentNodeTypes.isSubgraphType(node.typeId())); + } + + public boolean enterDefinition(NodeId nodeId) { + NodeInstance node = currentState().nodes.get(nodeId); + if (node == null || (!DocumentNodeTypes.isDefinitionType(node.typeId()) && !DocumentNodeTypes.isSubgraphType(node.typeId()))) { + return false; + } + String definitionId = DocumentNodeTypes.isDefinitionType(node.typeId()) + ? DocumentNodeTypes.definitionIdFromTypeId(node.typeId()) + : DocumentNodeTypes.subgraphIdFromTypeId(node.typeId()); + if (!definitions.containsKey(definitionId)) { + return false; + } + contextPath.add(definitionId); + contextNodePath.add(nodeId); + clearSelection(); + pendingConnection = null; + NodeId first = currentState().nodes.keySet().stream().findFirst().orElse(null); + if (first != null) { + selectNode(first); + } + refreshExecutionHighlights(); + return true; + } + + public boolean exitToBreadcrumb(String definitionId) { + if (definitionId == null) { + contextPath.clear(); + contextNodePath.clear(); + } else { + int index = contextPath.indexOf(definitionId); + if (index < 0) { + return false; + } + contextPath.subList(index + 1, contextPath.size()).clear(); + contextNodePath.subList(index + 1, contextNodePath.size()).clear(); + } + clearSelection(); + pendingConnection = null; + NodeId first = currentState().nodes.keySet().stream().findFirst().orElse(null); + if (first != null) { + selectNode(first); + } + refreshExecutionHighlights(); + return true; + } + + public List definitions() { + return definitions.values().stream() + .filter(definition -> definition.kind == DocumentNodeDefinitionKind.CUSTOM_NODE) + .map(this::toCustomDefinition) + .toList(); + } + + public DocumentNodeDefinition createBlankDefinition(DocumentNodeDefinitionKind kind, double x, double y) { + EditorSnapshot before = captureSnapshot(); + String definitionId = NodeId.random().value(); + String name = nextDefinitionName(kind); + DefinitionState definition = new DefinitionState(definitionId, name, kind, new MutableGraphState()); + definitionParents.put(definitionId, kind == DocumentNodeDefinitionKind.SUBGRAPH ? currentParentRef() : null); + definitions.put(definitionId, definition); + refreshRuntime(); + + String typeId = kind == DocumentNodeDefinitionKind.SUBGRAPH + ? DocumentNodeTypes.subgraphTypeId(definitionId) + : DocumentNodeTypes.definitionTypeId(definitionId); + NodeType nodeType = runtimeRegistry().getOrThrow(typeId); + NodeId nodeId = NodeId.random(); + currentState().nodes.put(nodeId, createNode(nodeId, nodeType)); + currentState().positions.put(nodeId, new NodePosition(x, y)); + selectNode(nodeId); + enterDefinition(nodeId); + finishMutation(before); + return new DocumentNodeDefinition(definitionId, name, new GraphScope(definition.graphState.graph(), definition.graphState.layout(), List.of(), List.of())); + } + + public boolean createDefinitionFromSelection(DocumentNodeDefinitionKind kind) { + if (selectedNodeIds.isEmpty()) { + showTranslatedMessage("mcng.ui.message.select_node", "Select at least one node"); + return false; + } + + Set selection = new LinkedHashSet<>(selectedNodeIds); + int eventSources = 0; + for (NodeId nodeId : selection) { + NodeType nodeType = nodeType(requireNode(nodeId)); + if (nodeType.kind() == NodeKind.EVENT_SOURCE) { + eventSources++; + } + } + if (kind == DocumentNodeDefinitionKind.CUSTOM_NODE && eventSources > 0) { + showTranslatedMessage("mcng.ui.message.custom_node_contains_event", "Custom nodes cannot contain event sources"); + return false; + } + + EditorSnapshot before = captureSnapshot(); + MutableGraphState workspace = currentState(); + Bounds bounds = selectionBounds(selection, workspace.positions, workspace.sizes); + String definitionId = NodeId.random().value(); + String name = nextDefinitionName(kind); + MutableGraphState definitionGraph = new MutableGraphState(); + Map outputPorts = new LinkedHashMap<>(); + + for (NodeId nodeId : selection) { + NodeInstance node = workspace.nodes.get(nodeId); + definitionGraph.nodes.put(nodeId, node); + NodePosition position = workspace.positions.getOrDefault(nodeId, new NodePosition(0, 0)); + definitionGraph.positions.put(nodeId, new NodePosition(position.x() - bounds.minX() + 80, position.y() - bounds.minY() + 40)); + NodeSize size = workspace.sizes.get(nodeId); + if (size != null) { + definitionGraph.sizes.put(nodeId, size); + } + } + + List remainingEdges = new ArrayList<>(); + for (EdgeDefinition edge : workspace.edges) { + boolean fromSelected = selection.contains(edge.fromNodeId()); + boolean toSelected = selection.contains(edge.toNodeId()); + if (fromSelected && toSelected) { + definitionGraph.edges.add(edge); + continue; + } + if (!fromSelected && toSelected) { + NodeId graphInputId = NodeId.random(); + PortDefinition targetPort = requirePort(workspace.nodes.get(edge.toNodeId()), edge.toPortId(), PortDirection.INPUT); + PortChannel channel = resolvePortChannel(workspace.graph(), edge.toNodeId(), edge.toPortId(), PortDirection.INPUT); + if (kind == DocumentNodeDefinitionKind.CUSTOM_NODE && channel == PortChannel.CONTROL) { + showTranslatedMessage("mcng.ui.message.custom_node_control_boundary", "Custom nodes cannot expose control flow"); + return false; + } + definitionGraph.nodes.put(graphInputId, createGraphPortNode( + graphInputId, + channel == PortChannel.CONTROL + ? kind == DocumentNodeDefinitionKind.SUBGRAPH ? DocumentNodeTypes.SUBGRAPH_FLOW_INPUT : DocumentNodeTypes.FLOW_INPUT + : kind == DocumentNodeDefinitionKind.SUBGRAPH ? DocumentNodeTypes.SUBGRAPH_INPUT : DocumentNodeTypes.GRAPH_INPUT, + targetPort.name() + )); + NodePosition targetPosition = definitionGraph.positions.getOrDefault(edge.toNodeId(), new NodePosition(80, 40)); + definitionGraph.positions.put(graphInputId, new NodePosition(20, targetPosition.y())); + definitionGraph.edges.add(new EdgeDefinition( + graphInputId, + channel == PortChannel.CONTROL ? new PortId("flow") : new PortId("value"), + edge.toNodeId(), + edge.toPortId() + )); + remainingEdges.add(new EdgeDefinition(edge.fromNodeId(), edge.fromPortId(), new NodeId("__PENDING__" + graphInputId.value()), new PortId(graphInputId.value()))); + continue; + } + if (fromSelected && !toSelected) { + PortIdKey key = new PortIdKey(edge.fromNodeId(), edge.fromPortId()); + PortChannel channel = resolvePortChannel(workspace.graph(), edge.fromNodeId(), edge.fromPortId(), PortDirection.OUTPUT); + if (kind == DocumentNodeDefinitionKind.CUSTOM_NODE && channel == PortChannel.CONTROL) { + showTranslatedMessage("mcng.ui.message.custom_node_control_boundary", "Custom nodes cannot expose control flow"); + return false; + } + NodeId graphOutputId = outputPorts.computeIfAbsent(key, ignored -> { + NodeId newId = NodeId.random(); + PortDefinition sourcePort = requirePort(workspace.nodes.get(edge.fromNodeId()), edge.fromPortId(), PortDirection.OUTPUT); + definitionGraph.nodes.put(newId, createGraphPortNode( + newId, + channel == PortChannel.CONTROL + ? kind == DocumentNodeDefinitionKind.SUBGRAPH ? DocumentNodeTypes.SUBGRAPH_FLOW_OUTPUT : DocumentNodeTypes.FLOW_OUTPUT + : kind == DocumentNodeDefinitionKind.SUBGRAPH ? DocumentNodeTypes.SUBGRAPH_OUTPUT : DocumentNodeTypes.GRAPH_OUTPUT, + sourcePort.name() + )); + NodePosition sourcePosition = definitionGraph.positions.getOrDefault(edge.fromNodeId(), new NodePosition(80, 40)); + definitionGraph.positions.put(newId, new NodePosition(bounds.width() + 140, sourcePosition.y())); + definitionGraph.edges.add(new EdgeDefinition( + edge.fromNodeId(), + edge.fromPortId(), + newId, + channel == PortChannel.CONTROL ? new PortId("flow") : new PortId("value") + )); + return newId; + }); + remainingEdges.add(new EdgeDefinition(new NodeId("__PENDING__" + graphOutputId.value()), new PortId(graphOutputId.value()), edge.toNodeId(), edge.toPortId())); + continue; + } + remainingEdges.add(edge); + } + + DefinitionState definition = new DefinitionState(definitionId, name, kind, definitionGraph); + definitions.put(definitionId, definition); + definitionParents.put(definitionId, kind == DocumentNodeDefinitionKind.SUBGRAPH ? currentParentRef() : null); + refreshRuntime(); + + String typeId = kind == DocumentNodeDefinitionKind.SUBGRAPH + ? DocumentNodeTypes.subgraphTypeId(definitionId) + : DocumentNodeTypes.definitionTypeId(definitionId); + NodeType definitionNodeType = runtimeRegistry().getOrThrow(typeId); + NodeId replacementNodeId = NodeId.random(); + NodePosition replacementPosition = new NodePosition(bounds.centerX() - 98, bounds.centerY() - 40); + workspace.nodes.keySet().removeIf(selection::contains); + workspace.positions.keySet().removeIf(selection::contains); + workspace.sizes.keySet().removeIf(selection::contains); + workspace.edges.clear(); + workspace.nodes.put(replacementNodeId, createNode(replacementNodeId, definitionNodeType)); + workspace.positions.put(replacementNodeId, replacementPosition); + for (EdgeDefinition edge : remainingEdges) { + if (edge.fromNodeId().value().startsWith("__PENDING__")) { + String definitionPortNodeId = edge.fromNodeId().value().substring("__PENDING__".length()); + workspace.edges.add(new EdgeDefinition(replacementNodeId, new PortId(definitionPortNodeId), edge.toNodeId(), edge.toPortId())); + } else if (edge.toNodeId().value().startsWith("__PENDING__")) { + String definitionPortNodeId = edge.toNodeId().value().substring("__PENDING__".length()); + workspace.edges.add(new EdgeDefinition(edge.fromNodeId(), edge.fromPortId(), replacementNodeId, new PortId(definitionPortNodeId))); + } else { + workspace.edges.add(edge); + } + } + + selectNode(replacementNodeId); + return finishMutation(before); + } + + private NodeTypeRegistry runtimeRegistry() { + if (resolvedRegistry == null) { + refreshRuntime(); + } + return resolvedRegistry; + } + + private void refreshRuntime() { + refreshRuntime(document()); + } + + private void refreshRuntime(GraphDocument document) { + cancelExecutionSilently(); + compiledDocument = new GraphExecutor(baseRegistry, portTypes).compile(document); + resolvedRegistry = compiledDocument.resolvedRegistry(); + lastErrors = compiledDocument.compileErrors(); + lastExecutionSnapshot = emptySnapshot(); + refreshExecutionHighlights(); + } + + private boolean localClipboardContainsHelpers() { + return localClipboard.graph().nodes().stream().anyMatch(node -> DocumentNodeTypes.isHelperType(node.typeId())); + } + + private void collectSubgraphDefinitions(String typeId, Map definitionsBySourceId, Set visiting) { + if (!DocumentNodeTypes.isSubgraphType(typeId)) { + return; + } + String definitionId = DocumentNodeTypes.subgraphIdFromTypeId(typeId); + DefinitionState definition = definitions.get(definitionId); + if (definition == null || definition.kind != DocumentNodeDefinitionKind.SUBGRAPH || definitionsBySourceId.containsKey(definitionId) || !visiting.add(definitionId)) { + return; + } + definitionsBySourceId.put( + definitionId, + new ClipboardDefinition( + definitionId, + definition.displayName, + new GraphScope( + copyGraph(definition.graphState.graph()), + copyLayout(definition.graphState.layout()), + new ArrayList<>(definition.variables.values()), + subgraphsForParent(new ParentScopeRef(definition.id)) + ) + ) + ); + } + + private GraphDefinition rewriteGraph(GraphDefinition graph, Map definitionIdMap) { + return new GraphDefinition( + graph.nodes().stream().map(node -> rewriteNode(node, node.id(), definitionIdMap)).toList(), + graph.edges().stream().map(this::copyEdgeDefinition).toList() + ); + } + + private GraphDefinition copyGraph(GraphDefinition graph) { + return new GraphDefinition( + graph.nodes().stream().map(this::copyNodeInstance).toList(), + graph.edges().stream().map(this::copyEdgeDefinition).toList() + ); + } + + private GraphLayout copyLayout(GraphLayout layout) { + Map positions = new LinkedHashMap<>(); + layout.nodePositions().forEach((nodeId, position) -> positions.put(nodeId, new NodePosition(position.x(), position.y()))); + Map sizes = new LinkedHashMap<>(); + layout.nodeSizes().forEach((nodeId, size) -> sizes.put(nodeId, new NodeSize(size.width(), size.height()))); + return new GraphLayout(positions, sizes); + } + + private NodeInstance rewriteNode(NodeInstance source, NodeId nodeId, Map definitionIdMap) { + String typeId = source.typeId(); + if (DocumentNodeTypes.isDefinitionType(typeId)) { + String sourceDefinitionId = DocumentNodeTypes.definitionIdFromTypeId(typeId); + String mappedDefinitionId = definitionIdMap.get(sourceDefinitionId); + if (mappedDefinitionId != null) { + typeId = DocumentNodeTypes.definitionTypeId(mappedDefinitionId); + } + } else if (DocumentNodeTypes.isSubgraphType(typeId)) { + String sourceDefinitionId = DocumentNodeTypes.subgraphIdFromTypeId(typeId); + String mappedDefinitionId = definitionIdMap.get(sourceDefinitionId); + if (mappedDefinitionId != null) { + typeId = DocumentNodeTypes.subgraphTypeId(mappedDefinitionId); + } + } + return new NodeInstance(nodeId, typeId, source.config()); + } + + private GraphScope rewriteScope(GraphScope scope, Map definitionIdMap) { + return new GraphScope( + rewriteGraph(scope.graph(), definitionIdMap), + copyLayout(scope.layout()), + scope.variables(), + scope.subgraphs().stream() + .map(subgraph -> new SubgraphDefinition( + definitionIdMap.getOrDefault(subgraph.id(), subgraph.id()), + subgraph.displayName(), + rewriteScope(subgraph.scope(), definitionIdMap) + )) + .toList() + ); + } + + private void collectClipboardSubgraphIds(GraphScope scope, Map definitionIdMap) { + for (SubgraphDefinition subgraph : scope.subgraphs()) { + definitionIdMap.putIfAbsent(subgraph.id(), NodeId.random().value()); + collectClipboardSubgraphIds(subgraph.scope(), definitionIdMap); + } + } + + private NodeInstance copyNodeInstance(NodeInstance source) { + return new NodeInstance(source.id(), source.typeId(), source.config()); + } + + private EdgeDefinition copyEdgeDefinition(EdgeDefinition edge) { + return new EdgeDefinition(edge.fromNodeId(), edge.fromPortId(), edge.toNodeId(), edge.toPortId()); + } + + private boolean connectInternal(PendingConnection first, PendingConnection second) { + if (first.direction() == second.direction()) { + showTranslatedMessage("mcng.ui.message.connect_output_to_input", "Connect an output port to an input port"); + return false; + } + + PendingConnection output = first.direction() == PortDirection.OUTPUT ? first : second; + PendingConnection input = first.direction() == PortDirection.INPUT ? first : second; + NodeInstance fromNode = currentState().nodes.get(output.nodeId()); + NodeInstance toNode = currentState().nodes.get(input.nodeId()); + requirePort(fromNode, output.portId(), PortDirection.OUTPUT); + requirePort(toNode, input.portId(), PortDirection.INPUT); + + GraphDefinition candidateGraph = candidateGraphForConnection(output, input); + if (candidateGraph == null) { + showTranslatedMessage("mcng.ui.message.reroute_has_source", "Reroute chain already has an incoming source"); + return false; + } + PortChannel fromChannel; + PortChannel toChannel; + try { + fromChannel = resolvePortChannel(candidateGraph, output.nodeId(), output.portId(), PortDirection.OUTPUT); + toChannel = resolvePortChannel(candidateGraph, input.nodeId(), input.portId(), PortDirection.INPUT); + } catch (IllegalArgumentException exception) { + host.showMessage(exception.getMessage()); + return false; + } + if (fromChannel != toChannel) { + showTranslatedMessage( + "mcng.ui.message.port_channel_mismatch", + "Port channels are not compatible: %s -> %s", + channelLabel(fromChannel), + channelLabel(toChannel) + ); + return false; + } + + if (fromChannel == PortChannel.DATA) { + ResolvedPortType fromResolution = resolvePortType(currentDepth(), candidateGraph, output.nodeId(), output.portId(), PortDirection.OUTPUT); + ResolvedPortType toResolution = resolvePortType(currentDepth(), candidateGraph, input.nodeId(), input.portId(), PortDirection.INPUT); + if (!GraphPortTypeResolver.canConnect(fromResolution, toResolution, portTypes)) { + PortType fromType = fromResolution.effectiveType(); + PortType toType = toResolution.effectiveType(); + showTranslatedMessage( + "mcng.ui.message.port_type_mismatch", + "Port types are not compatible: %s -> %s", + portTypeLabel(fromType), + portTypeLabel(toType) + ); + return false; + } + } + + currentState().edges.clear(); + currentState().edges.addAll(candidateGraph.edges()); + return true; + } + + private GraphDefinition candidateGraphForConnection(PendingConnection output, PendingConnection input) { + List edges = currentState().edges.stream() + .map(this::copyEdgeDefinition) + .collect(ArrayList::new, List::add, List::addAll); + NodeInstance inputNode = currentState().nodes.get(input.nodeId()); + if (isReroute(input.nodeId(), inputNode) && input.portId().equals(BuiltinNodeTypes.VALUE_PORT)) { + if (!prepareRerouteInput(edges, input.nodeId(), true, new LinkedHashSet<>())) { + return null; + } + } else { + edges.removeIf(edge -> edge.toNodeId().equals(input.nodeId()) && edge.toPortId().equals(input.portId())); + } + + EdgeDefinition candidateEdge = new EdgeDefinition(output.nodeId(), output.portId(), input.nodeId(), input.portId()); + edges.removeIf(edge -> sameEdge(edge, candidateEdge)); + edges.add(candidateEdge); + return new GraphDefinition(new ArrayList<>(currentState().nodes.values()), edges); + } + + private boolean prepareRerouteInput(List edges, NodeId rerouteNodeId, boolean allowReplaceExternal, Set visiting) { + if (!visiting.add(rerouteNodeId)) { + return false; + } + EdgeDefinition incoming = edges.stream() + .filter(edge -> edge.toNodeId().equals(rerouteNodeId) && edge.toPortId().equals(BuiltinNodeTypes.VALUE_PORT)) + .findFirst() + .orElse(null); + if (incoming == null) { + return true; + } + + NodeInstance sourceNode = currentState().nodes.get(incoming.fromNodeId()); + if (!isReroute(incoming.fromNodeId(), sourceNode)) { + if (!allowReplaceExternal) { + return false; + } + edges.remove(incoming); + return true; + } + if (!prepareRerouteInput(edges, incoming.fromNodeId(), false, visiting)) { + return false; + } + + edges.remove(incoming); + EdgeDefinition reversed = new EdgeDefinition(rerouteNodeId, BuiltinNodeTypes.VALUE_PORT, incoming.fromNodeId(), BuiltinNodeTypes.VALUE_PORT); + edges.removeIf(edge -> sameEdge(edge, reversed)); + edges.add(reversed); + return true; + } + + private boolean sameEdge(EdgeDefinition left, EdgeDefinition right) { + return left.fromNodeId().equals(right.fromNodeId()) + && left.fromPortId().equals(right.fromPortId()) + && left.toNodeId().equals(right.toNodeId()) + && left.toPortId().equals(right.toPortId()); + } + + private PortChannel resolvePortChannel(int depth, NodeId nodeId, PortId portId, PortDirection direction) { + return resolvePortChannel(graphAtDepth(depth), nodeId, portId, direction); + } + + private PortChannel resolvePortChannel(GraphDefinition graph, NodeId nodeId, PortId portId, PortDirection direction) { + return GraphPortTypeResolver.resolveChannel(graph, runtimeRegistry(), nodeId, portId, direction); + } + + private ResolvedPortType resolvePortType(int depth, NodeId nodeId, PortId portId, PortDirection direction) { + return resolvePortType(depth, graphAtDepth(depth), nodeId, portId, direction); + } + + private ResolvedPortType resolvePortType(int depth, GraphDefinition graph, NodeId nodeId, PortId portId, PortDirection direction) { + return resolvePortType(depth, graph, nodeId, portId, direction, Set.of()); + } + + private ResolvedPortType resolvePortType(int depth, GraphDefinition graph, NodeId nodeId, PortId portId, PortDirection direction, Set visiting) { + ScopedPortRef currentRef = new ScopedPortRef(depth, new PortRef(nodeId, portId, direction)); + if (visiting.contains(currentRef)) { + PortDefinition definition = requirePortDefinition(graph, nodeId, portId, direction); + if (resolvePortChannel(graph, nodeId, portId, direction) == PortChannel.CONTROL) { + return new ResolvedPortType(definition, MCNGPortTypes.ANY, false, false); + } + if (definition.numericFamily()) { + return new ResolvedPortType(definition, MCNGPortTypes.ANY, false, true); + } + return definition.genericGroupId() != null + ? new ResolvedPortType(definition, definition.type(), true, false) + : new ResolvedPortType(definition, definition.type(), false, false); + } + + Set nextVisiting = new LinkedHashSet<>(visiting); + nextVisiting.add(currentRef); + + NodeInstance node = findNode(graph, nodeId); + if (node == null) { + throw new IllegalArgumentException("Unknown node " + nodeId); + } + PortDefinition definition = requirePortDefinition(graph, nodeId, portId, direction); + if (resolvePortChannel(graph, nodeId, portId, direction) == PortChannel.CONTROL) { + return new ResolvedPortType(definition, MCNGPortTypes.ANY, false, false); + } + ResolvedPortType dynamic = BuiltinNodeTypes.resolveDynamicPortType(node, definition, new DynamicPortTypeResolverContext() { + @Override + public NodeInstance node(NodeId candidate) { + NodeInstance candidateNode = findNode(graph, candidate); + if (candidateNode == null) { + throw new IllegalArgumentException("Unknown node " + candidate); + } + return candidateNode; + } + + @Override + public ResolvedPortType resolve(NodeId candidateNodeId, PortId candidatePortId, PortDirection candidateDirection) { + return resolvePortType(depth, graph, candidateNodeId, candidatePortId, candidateDirection, nextVisiting); + } + + @Override + public Object readInlineInputValue(NodeId candidateNodeId, PortId candidatePortId) { + NodeInstance candidateNode = findNode(graph, candidateNodeId); + if (candidateNode == null) { + throw new IllegalArgumentException("Unknown node " + candidateNodeId); + } + PortDefinition candidatePort = requirePortDefinition(graph, candidateNodeId, candidatePortId, PortDirection.INPUT); + return NodeConfigValues.readInlineInputValue(candidateNode.config(), candidatePort); + } + + @Override + public PortType variableType(String variableId) { + return visibleVariableType(depth, variableId); + } + + @Override + public Iterable edges() { + return graph.edges(); + } + }); + if (dynamic != null) { + return dynamic; + } + if (definition.genericGroupId() == null) { + if (definition.numericFamily()) { + PortType inferredInlineType = inferNumericInlineType(node, definition); + return new ResolvedPortType(definition, inferredInlineType != null ? inferredInlineType : MCNGPortTypes.ANY, false, true); + } + return new ResolvedPortType(definition, definition.type(), false, false); + } + + PortType parentResolved = resolveFromParentSubgraphContext(depth, nodeId, portId, direction, nextVisiting); + if (parentResolved != null) { + return new ResolvedPortType(definition, parentResolved, false, definition.numericFamily()); + } + + PortType resolved = resolveGenericGroupType(depth, graph, new GroupKey(nodeId, definition.genericGroupId()), Set.of(), nextVisiting); + if (resolved == null) { + return definition.numericFamily() + ? new ResolvedPortType(definition, fallbackNumericType(node, definition), false, true) + : new ResolvedPortType(definition, definition.type(), true, false); + } + return new ResolvedPortType(definition, resolved, false, definition.numericFamily()); + } + + private PortType fallbackNumericType(NodeInstance node, PortDefinition definition) { + PortType inferredInlineType = inferNumericInlineType(node, definition); + return inferredInlineType != null ? inferredInlineType : MCNGPortTypes.ANY; + } + + private PortType inferNumericInlineType(NodeInstance node, PortDefinition definition) { + if (definition.direction() != PortDirection.INPUT || !definition.numericFamily() || definition.inlineWidget() == null) { + return null; + } + Object inlineValue; + try { + inlineValue = NodeConfigValues.readInlineInputValue(node.config(), definition); + } catch (IllegalArgumentException exception) { + return null; + } + return inlineValue != null && NumericTypes.isNumericValue(inlineValue) ? NumericTypes.typeOf(inlineValue) : null; + } + + private PortType resolveGenericGroupType(int depth, GraphDefinition graph, GroupKey groupKey, Set visiting, Set portVisiting) { + ScopedGroupKey scopedGroupKey = new ScopedGroupKey(depth, groupKey); + if (visiting.contains(scopedGroupKey)) { + return null; + } + if (isRerouteGroup(graph, groupKey)) { + return resolveRerouteComponentType(depth, graph, groupKey.nodeId(), visiting, portVisiting); + } + + Set visited = new LinkedHashSet<>(visiting); + visited.add(scopedGroupKey); + + for (EdgeDefinition edge : graph.edges()) { + PortType inferred = inferFromEdge(depth, graph, groupKey, edge, visited, portVisiting); + if (inferred != null) { + return inferred; + } + } + PortType inferredInlineType = inferNumericGroupTypeFromInlineInputs(graph, groupKey); + if (inferredInlineType != null) { + return inferredInlineType; + } + return null; + } + + private PortType inferNumericGroupTypeFromInlineInputs(GraphDefinition graph, GroupKey groupKey) { + NodeInstance node = findNode(graph, groupKey.nodeId()); + if (node == null) { + return null; + } + NodeType nodeType = runtimeRegistry().find(node.typeId()).orElse(null); + if (nodeType == null) { + return null; + } + + PortType resolved = null; + for (PortDefinition input : nodeType.inputs(node)) { + if (!groupKey.groupId().equals(input.genericGroupId())) { + continue; + } + PortType inferredInlineType = inferNumericInlineType(node, input); + if (inferredInlineType == null) { + continue; + } + resolved = resolved == null ? inferredInlineType : NumericTypes.widen(resolved, inferredInlineType); + } + return resolved; + } + + private PortType resolveRerouteComponentType( + int depth, + GraphDefinition graph, + NodeId rerouteNodeId, + Set visiting, + Set portVisiting + ) { + RerouteComponent component = collectRerouteComponent(graph, rerouteNodeId); + for (PortRef anchor : component.anchors()) { + PortDefinition definition = requirePortDefinition(graph, anchor.nodeId(), anchor.portId(), anchor.direction()); + if (definition.channel() != PortChannel.DATA) { + continue; + } + PortType inferred = inferFromAnchor(depth, graph, anchor, visiting, portVisiting); + if (inferred != null) { + return inferred; + } + } + return null; + } + + private PortType inferFromAnchor( + int depth, + GraphDefinition graph, + PortRef anchor, + Set visiting, + Set portVisiting + ) { + PortDefinition definition = requirePortDefinition(graph, anchor.nodeId(), anchor.portId(), anchor.direction()); + if (definition.genericGroupId() != null) { + PortType resolved = resolveGenericGroupType(depth, graph, new GroupKey(anchor.nodeId(), definition.genericGroupId()), visiting, portVisiting); + if (resolved == null || resolved.equals(MCNGPortTypes.ANY)) { + return null; + } + return resolved; + } + if (definition.numericFamily() && anchor.direction() == PortDirection.INPUT) { + return null; + } + ResolvedPortType resolved = resolvePortType(depth, graph, anchor.nodeId(), definition.id(), anchor.direction(), portVisiting); + if (resolved.unresolvedGeneric() || resolved.unresolvedNumeric()) { + return null; + } + return resolved.effectiveType(); + } + + private RerouteComponent collectRerouteComponent(GraphDefinition graph, NodeId rerouteNodeId) { + Set rerouteNodeIds = new LinkedHashSet<>(); + List anchors = new ArrayList<>(); + ArrayList queue = new ArrayList<>(); + queue.add(rerouteNodeId); + int index = 0; + while (index < queue.size()) { + NodeId currentNodeId = queue.get(index++); + if (!rerouteNodeIds.add(currentNodeId)) { + continue; + } + NodeInstance currentNode = findNode(graph, currentNodeId); + if (!isReroute(currentNodeId, currentNode)) { + continue; + } + for (EdgeDefinition edge : graph.edges()) { + PortRef opposite = rerouteOpposite(edge, currentNodeId); + if (opposite == null) { + continue; + } + NodeInstance oppositeNode = findNode(graph, opposite.nodeId()); + if (oppositeNode != null && isReroute(opposite.nodeId(), oppositeNode)) { + queue.add(opposite.nodeId()); + continue; + } + anchors.add(opposite); + } + } + return new RerouteComponent(Set.copyOf(rerouteNodeIds), List.copyOf(anchors)); + } + + private PortRef rerouteOpposite(EdgeDefinition edge, NodeId rerouteNodeId) { + if (edge.fromNodeId().equals(rerouteNodeId) && edge.fromPortId().equals(BuiltinNodeTypes.VALUE_PORT)) { + return new PortRef(edge.toNodeId(), edge.toPortId(), PortDirection.INPUT); + } + if (edge.toNodeId().equals(rerouteNodeId) && edge.toPortId().equals(BuiltinNodeTypes.VALUE_PORT)) { + return new PortRef(edge.fromNodeId(), edge.fromPortId(), PortDirection.OUTPUT); + } + return null; + } + + private PortType inferFromEdge( + int depth, + GraphDefinition graph, + GroupKey groupKey, + EdgeDefinition edge, + Set visiting, + Set portVisiting + ) { + PortDefinition fromDefinition = findPortDefinition(graph, edge.fromNodeId(), edge.fromPortId(), PortDirection.OUTPUT); + PortDefinition toDefinition = findPortDefinition(graph, edge.toNodeId(), edge.toPortId(), PortDirection.INPUT); + if (fromDefinition == null || toDefinition == null) { + return null; + } + if (resolvePortChannel(graph, edge.fromNodeId(), edge.fromPortId(), PortDirection.OUTPUT) != PortChannel.DATA + || resolvePortChannel(graph, edge.toNodeId(), edge.toPortId(), PortDirection.INPUT) != PortChannel.DATA) { + return null; + } + + if (matchesGroup(edge.fromNodeId(), fromDefinition, groupKey)) { + return inferFromOpposite(depth, graph, edge.toNodeId(), toDefinition, PortDirection.INPUT, visiting, portVisiting); + } + if (matchesGroup(edge.toNodeId(), toDefinition, groupKey)) { + return inferFromOpposite(depth, graph, edge.fromNodeId(), fromDefinition, PortDirection.OUTPUT, visiting, portVisiting); + } + return null; + } + + private PortType inferFromOpposite( + int depth, + GraphDefinition graph, + NodeId nodeId, + PortDefinition definition, + PortDirection direction, + Set visiting, + Set portVisiting + ) { + if (definition.genericGroupId() != null) { + PortType resolved = resolveGenericGroupType(depth, graph, new GroupKey(nodeId, definition.genericGroupId()), visiting, portVisiting); + if (resolved != null && !resolved.equals(com.github.squi2rel.mcng.core.MCNGPortTypes.ANY)) { + return resolved; + } + + PortType parentResolved = resolveFromParentSubgraphContext(depth, nodeId, definition.id(), direction, portVisiting); + if (parentResolved != null && !parentResolved.equals(com.github.squi2rel.mcng.core.MCNGPortTypes.ANY)) { + return parentResolved; + } + return null; + } + if (definition.numericFamily() && direction == PortDirection.INPUT) { + return null; + } + ResolvedPortType resolved = resolvePortType(depth, graph, nodeId, definition.id(), direction, portVisiting); + if (resolved.unresolvedGeneric() || resolved.unresolvedNumeric()) { + return null; + } + return resolved.effectiveType(); + } + + private PortType resolveFromParentSubgraphContext(int depth, NodeId nodeId, PortId portId, PortDirection direction, Set visiting) { + if (depth == 0 || !new PortId("value").equals(portId)) { + return null; + } + + DefinitionState definition = definitionAtDepth(depth); + if (definition == null || definition.kind != DocumentNodeDefinitionKind.SUBGRAPH) { + return null; + } + + NodeInstance node = findNode(graphAtDepth(depth), nodeId); + if (node == null) { + return null; + } + + PortDirection parentDirection; + if (DocumentNodeTypes.SUBGRAPH_INPUT_TYPE_ID.equals(node.typeId()) && direction == PortDirection.OUTPUT) { + parentDirection = PortDirection.INPUT; + } else if (DocumentNodeTypes.SUBGRAPH_OUTPUT_TYPE_ID.equals(node.typeId()) && direction == PortDirection.INPUT) { + parentDirection = PortDirection.OUTPUT; + } else { + return null; + } + + NodeId parentNodeId = contextNodePath.get(depth - 1); + ResolvedPortType resolution = resolvePortType(depth - 1, graphAtDepth(depth - 1), parentNodeId, new PortId(node.id().value()), parentDirection, visiting); + return resolution.unresolvedGeneric() || resolution.unresolvedNumeric() ? null : resolution.effectiveType(); + } + + private RerouteOrientation legacyRerouteOrientation(NodeId nodeId, JsonObject config) { + Boolean legacyVertical = explicitLegacyRerouteVertical(config); + boolean vertical = legacyVertical != null && legacyVertical; + Boolean legacyFlipped = explicitLegacyRerouteFlip(config); + NodeWidget.PortSide inputSide = legacyFlipped != null + ? legacyInputSide(vertical, legacyFlipped) + : inferRerouteInputSide(nodeId, vertical); + return RerouteOrientation.fromInputSide(inputSide); + } + + private NodeWidget.PortSide legacyInputSide(boolean vertical, boolean flipped) { + if (vertical) { + return flipped ? NodeWidget.PortSide.BOTTOM : NodeWidget.PortSide.TOP; + } + return flipped ? NodeWidget.PortSide.RIGHT : NodeWidget.PortSide.LEFT; + } + + private NodeWidget.PortSide inferRerouteInputSide(NodeId nodeId, boolean vertical) { + NodePosition position = currentState().positions.getOrDefault(nodeId, new NodePosition(0, 0)); + int score = 0; + for (EdgeDefinition edge : currentState().edges) { + if (edge.toNodeId().equals(nodeId) && edge.toPortId().equals(BuiltinNodeTypes.VALUE_PORT)) { + NodePosition source = currentState().positions.getOrDefault(edge.fromNodeId(), new NodePosition(0, 0)); + score += vertical + ? Double.compare(source.y(), position.y()) + : Double.compare(source.x(), position.x()); + } + if (edge.fromNodeId().equals(nodeId) && edge.fromPortId().equals(BuiltinNodeTypes.VALUE_PORT)) { + NodePosition target = currentState().positions.getOrDefault(edge.toNodeId(), new NodePosition(0, 0)); + score -= vertical + ? Double.compare(target.y(), position.y()) + : Double.compare(target.x(), position.x()); + } + } + if (vertical) { + return score > 0 ? NodeWidget.PortSide.BOTTOM : NodeWidget.PortSide.TOP; + } + return score > 0 ? NodeWidget.PortSide.RIGHT : NodeWidget.PortSide.LEFT; + } + + private boolean setRerouteOrientation(NodeId nodeId, RerouteOrientation orientation) { + NodeInstance node = currentState().nodes.get(nodeId); + if (!isReroute(nodeId, node)) { + return false; + } + RerouteOrientation current = explicitRerouteOrientation(node.config()); + if (current == orientation) { + return false; + } + JsonObject updated = node.config().deepCopy(); + updated.addProperty(REROUTE_ORIENTATION_KEY, orientation.id()); + updated.remove(LEGACY_REROUTE_FLIPPED_KEY); + updated.remove(LEGACY_REROUTE_VERTICAL_KEY); + currentState().nodes.put(nodeId, new NodeInstance(node.id(), node.typeId(), updated)); + return true; + } + + private boolean isReroute(NodeId nodeId, NodeInstance node) { + return node != null + && currentState().nodes.containsKey(nodeId) + && BuiltinNodeTypes.REROUTE.id().equals(node.typeId()); + } + + private static RerouteOrientation explicitRerouteOrientation(JsonObject config) { + if (config == null || !config.has(REROUTE_ORIENTATION_KEY)) { + return null; + } + try { + return RerouteOrientation.parse(config.get(REROUTE_ORIENTATION_KEY).getAsString()); + } catch (RuntimeException exception) { + return null; + } + } + + private static Boolean explicitLegacyRerouteVertical(JsonObject config) { + if (config == null || !config.has(LEGACY_REROUTE_VERTICAL_KEY)) { + return null; + } + try { + return config.get(LEGACY_REROUTE_VERTICAL_KEY).getAsBoolean(); + } catch (RuntimeException exception) { + return null; + } + } + + private static Boolean explicitLegacyRerouteFlip(JsonObject config) { + if (config == null || !config.has(LEGACY_REROUTE_FLIPPED_KEY)) { + return null; + } + try { + return config.get(LEGACY_REROUTE_FLIPPED_KEY).getAsBoolean(); + } catch (RuntimeException exception) { + return null; + } + } + + private static PortDirection opposite(PortDirection direction) { + return direction == PortDirection.INPUT ? PortDirection.OUTPUT : PortDirection.INPUT; + } + + private NodeInstance requireNode(NodeId nodeId) { + NodeInstance node = currentState().nodes.get(nodeId); + if (node == null) { + throw new IllegalArgumentException("Unknown node " + nodeId); + } + return node; + } + + private PortDefinition requirePort(NodeInstance node, PortId portId, PortDirection direction) { + List ports = direction == PortDirection.INPUT ? nodeType(node).inputs(node) : nodeType(node).outputs(node); + return ports.stream() + .filter(port -> port.id().equals(portId)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown " + direction + " port " + portId + " on " + node.typeId())); + } + + private void pruneInvalidNodePorts(NodeId nodeId) { + NodeInstance node = currentState().nodes.get(nodeId); + if (node == null) { + return; + } + NodeType nodeType = runtimeRegistry().find(node.typeId()).orElse(null); + if (nodeType == null) { + return; + } + + Set validInputs = nodeType.inputs(node).stream() + .map(PortDefinition::id) + .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); + Set validOutputs = nodeType.outputs(node).stream() + .map(PortDefinition::id) + .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); + + currentState().edges.removeIf(edge -> + (edge.toNodeId().equals(nodeId) && !validInputs.contains(edge.toPortId())) + || (edge.fromNodeId().equals(nodeId) && !validOutputs.contains(edge.fromPortId())) + ); + if (pendingConnection != null && pendingConnection.nodeId().equals(nodeId)) { + boolean valid = pendingConnection.direction() == PortDirection.INPUT + ? validInputs.contains(pendingConnection.portId()) + : validOutputs.contains(pendingConnection.portId()); + if (!valid) { + pendingConnection = null; + } + } + } + + private NodeInstance findNode(GraphDefinition graph, NodeId nodeId) { + return graph.nodes().stream().filter(candidate -> candidate.id().equals(nodeId)).findFirst().orElse(null); + } + + private PortDefinition requirePortDefinition(GraphDefinition graph, NodeId nodeId, PortId portId, PortDirection direction) { + PortDefinition definition = findPortDefinition(graph, nodeId, portId, direction); + if (definition == null) { + throw new IllegalArgumentException("Unknown " + direction + " port " + portId + " on " + nodeId); + } + return definition; + } + + private PortDefinition findPortDefinition(GraphDefinition graph, NodeId nodeId, PortId portId, PortDirection direction) { + NodeInstance node = findNode(graph, nodeId); + if (node == null) { + return null; + } + NodeType nodeType = runtimeRegistry().find(node.typeId()).orElse(null); + if (nodeType == null) { + return null; + } + List ports = direction == PortDirection.INPUT ? nodeType.inputs(node) : nodeType.outputs(node); + return ports.stream().filter(port -> port.id().equals(portId)).findFirst().orElse(null); + } + + private boolean matchesGroup(NodeId nodeId, PortDefinition definition, GroupKey groupKey) { + return definition.genericGroupId() != null + && nodeId.equals(groupKey.nodeId()) + && definition.genericGroupId().equals(groupKey.groupId()); + } + + private boolean isRerouteGroup(GraphDefinition graph, GroupKey groupKey) { + NodeInstance node = findNode(graph, groupKey.nodeId()); + if (!isReroute(groupKey.nodeId(), node)) { + return false; + } + PortDefinition input = findPortDefinition(graph, groupKey.nodeId(), BuiltinNodeTypes.VALUE_PORT, PortDirection.INPUT); + return input != null && groupKey.groupId().equals(input.genericGroupId()); + } + + @SuppressWarnings("unchecked") + private NodeInstance createNode(NodeId nodeId, NodeType nodeType) { + NodeType typed = (NodeType) nodeType; + return new NodeInstance(nodeId, typed.id(), typed.configCodec().toJson(typed.defaultConfig())); + } + + private NodeInstance createGraphPortNode(NodeId nodeId, NodeType nodeType, String defaultName) { + JsonObject config = nodeType.defaultConfig().deepCopy(); + config.addProperty("name", defaultName); + return new NodeInstance(nodeId, nodeType.id(), config); + } + + private void loadDocumentState(GraphDocument document) { + root.load(document.graph(), document.layout()); + definitions.clear(); + definitionParents.clear(); + for (DocumentNodeDefinition definition : document.definitions()) { + loadDefinition(definition, null); + } + loadSubgraphs(document.rootScope().subgraphs(), null); + rootVariables.clear(); + for (GraphVariableDefinition variable : document.variables()) { + rootVariables.put(variable.id(), variable); + } + } + + private void resetForLoadedDocument(boolean clearLocalClipboard) { + contextPath.clear(); + contextNodePath.clear(); + debugMessages.clear(); + pendingConnection = null; + clearSelection(); + if (!root.nodes.isEmpty()) { + selectNode(root.nodes.values().iterator().next().id()); + } + if (clearLocalClipboard) { + localClipboard = null; + } + } + + private void applySnapshot(EditorSnapshot snapshot) { + loadDocumentState(snapshot.document()); + contextPath.clear(); + contextNodePath.clear(); + restoreContext(snapshot.contextPath(), snapshot.contextNodePath()); + restoreSelection(snapshot.selectedNodeIds(), snapshot.primarySelectedNodeId()); + debugMessages.clear(); + pendingConnection = null; + refreshRuntime(snapshot.document()); + } + + private void restoreContext(List targetContextPath, List targetContextNodePath) { + MutableGraphState scope = root; + int limit = Math.min(targetContextPath.size(), targetContextNodePath.size()); + for (int index = 0; index < limit; index++) { + String definitionId = targetContextPath.get(index); + NodeId containerNodeId = targetContextNodePath.get(index); + DefinitionState definition = definitions.get(definitionId); + if (definition == null) { + break; + } + NodeInstance containerNode = scope.nodes.get(containerNodeId); + if (containerNode == null || !matchesDefinitionNode(containerNode, definitionId)) { + break; + } + contextPath.add(definitionId); + contextNodePath.add(containerNodeId); + scope = definition.graphState; + } + } + + private boolean matchesDefinitionNode(NodeInstance node, String definitionId) { + return DocumentNodeTypes.definitionTypeId(definitionId).equals(node.typeId()) + || DocumentNodeTypes.subgraphTypeId(definitionId).equals(node.typeId()); + } + + private void restoreSelection(List snapshotSelection, NodeId snapshotPrimarySelection) { + selectedNodeIds.clear(); + primarySelectedNodeId = null; + for (NodeId nodeId : snapshotSelection) { + if (currentState().nodes.containsKey(nodeId)) { + selectedNodeIds.add(nodeId); + } + } + if (snapshotPrimarySelection != null && selectedNodeIds.contains(snapshotPrimarySelection)) { + primarySelectedNodeId = snapshotPrimarySelection; + } else { + primarySelectedNodeId = selectedNodeIds.stream().findFirst().orElse(null); + } + } + + private EditorSnapshot captureSnapshot() { + return captureSnapshot(document()); + } + + private EditorSnapshot captureSnapshot(GraphDocument document) { + return new EditorSnapshot( + document, + List.copyOf(contextPath), + List.copyOf(contextNodePath), + List.copyOf(selectedNodeIds), + primarySelectedNodeId + ); + } + + private void pushHistory(Deque stack, HistoryEntry entry) { + if (entry == null) { + return; + } + stack.addLast(entry); + while (stack.size() > MAX_HISTORY_ENTRIES) { + stack.removeFirst(); + } + } + + private void clearHistoryState() { + undoStack.clear(); + redoStack.clear(); + compositeEditDepth = 0; + compositeEditStart = null; + compositeEditLatest = null; + compositeEditChanged = false; + } + + private void finishOpenCompositeEdit() { + while (compositeEditDepth > 0) { + endCompositeEdit(); + } + } + + private boolean finishMutation(EditorSnapshot before) { + GraphDocument after = document(); + if (before.document().equals(after)) { + return false; + } + EditorSnapshot afterSnapshot = captureSnapshot(after); + if (compositeEditDepth > 0) { + if (!compositeEditChanged) { + redoStack.clear(); + } + compositeEditChanged = true; + compositeEditLatest = afterSnapshot; + } else { + pushHistory(undoStack, new HistoryEntry(before, afterSnapshot)); + redoStack.clear(); + } + persist(after); + return true; + } + + private void loadDefinition(DocumentNodeDefinition definition, ParentScopeRef parent) { + DefinitionState state = new DefinitionState( + definition.id(), + definition.displayName(), + DocumentNodeDefinitionKind.CUSTOM_NODE, + new MutableGraphState(definition.graph(), definition.layout()) + ); + for (GraphVariableDefinition variable : definition.variables()) { + state.variables.put(variable.id(), variable); + } + definitions.put(definition.id(), state); + definitionParents.put(definition.id(), parent); + loadSubgraphs(definition.subgraphs(), new ParentScopeRef(definition.id())); + } + + private void loadSubgraphs(List subgraphs, ParentScopeRef parent) { + for (SubgraphDefinition subgraph : subgraphs) { + DefinitionState state = new DefinitionState( + subgraph.id(), + subgraph.displayName(), + DocumentNodeDefinitionKind.SUBGRAPH, + new MutableGraphState(subgraph.graph(), subgraph.layout()) + ); + for (GraphVariableDefinition variable : subgraph.scope().variables()) { + state.variables.put(variable.id(), variable); + } + definitions.put(subgraph.id(), state); + definitionParents.put(subgraph.id(), parent); + loadSubgraphs(subgraph.scope().subgraphs(), new ParentScopeRef(subgraph.id())); + } + } + + private DocumentNodeDefinition toCustomDefinition(DefinitionState definition) { + return new DocumentNodeDefinition( + definition.id, + definition.displayName, + new GraphScope( + definition.graphState.graph(), + definition.graphState.layout(), + new ArrayList<>(definition.variables.values()), + subgraphsForParent(new ParentScopeRef(definition.id)) + ) + ); + } + + private List subgraphsForParent(ParentScopeRef parent) { + List subgraphs = new ArrayList<>(); + for (DefinitionState definition : definitions.values()) { + if (definition.kind != DocumentNodeDefinitionKind.SUBGRAPH) { + continue; + } + if (!Objects.equals(definitionParents.get(definition.id), parent)) { + continue; + } + subgraphs.add(new SubgraphDefinition( + definition.id, + definition.displayName, + new GraphScope( + definition.graphState.graph(), + definition.graphState.layout(), + new ArrayList<>(definition.variables.values()), + subgraphsForParent(new ParentScopeRef(definition.id)) + ) + )); + } + return subgraphs; + } + + private PortType visibleVariableType(int depth, String variableId) { + DefinitionState definition = depth <= 0 ? null : definitionAtDepth(depth); + if (definition != null) { + GraphVariableDefinition local = definition.variables.get(variableId); + if (local != null) { + return portTypes.findType(local.typeId()).orElse(null); + } + } + for (int index = depth - 1; index >= 1; index--) { + DefinitionState ancestor = definitionAtDepth(index); + if (ancestor == null || ancestor.kind != DocumentNodeDefinitionKind.SUBGRAPH) { + continue; + } + GraphVariableDefinition local = ancestor.variables.get(variableId); + if (local != null) { + return portTypes.findType(local.typeId()).orElse(null); + } + } + GraphVariableDefinition rootVariable = rootVariables.get(variableId); + return rootVariable != null ? portTypes.findType(rootVariable.typeId()).orElse(null) : null; + } + + private MutableGraphState currentState() { + DefinitionState definition = currentDefinition(); + return definition == null ? root : definition.graphState; + } + + private DefinitionState currentDefinition() { + return contextPath.isEmpty() ? null : definitions.get(contextPath.getLast()); + } + + private Map currentVariableState() { + DefinitionState definition = currentDefinition(); + return definition == null ? rootVariables : definition.variables; + } + + private ParentScopeRef currentParentRef() { + return contextPath.isEmpty() ? null : new ParentScopeRef(contextPath.getLast()); + } + + private int currentDepth() { + return contextPath.size(); + } + + private DefinitionState definitionAtDepth(int depth) { + return depth <= 0 ? null : definitions.get(contextPath.get(depth - 1)); + } + + private GraphDefinition graphAtDepth(int depth) { + return depth == 0 ? root.graph() : definitionAtDepth(depth).graphState.graph(); + } + + private String nextDefinitionName(DocumentNodeDefinitionKind kind) { + String prefix = kind == DocumentNodeDefinitionKind.SUBGRAPH + ? translate("mcng.ui.default_name.subgraph", "Subgraph") + : translate("mcng.ui.default_name.custom_node", "Custom Node"); + int index = 1; + Set used = definitions.values().stream().map(definition -> definition.displayName).collect(LinkedHashSet::new, Set::add, Set::addAll); + while (used.contains(prefix + " " + index)) { + index++; + } + return prefix + " " + index; + } + + private String nextVariableId() { + int index = 1; + while (currentVariableState().containsKey("var_" + index)) { + index++; + } + return "var_" + index; + } + + private JsonElement defaultVariableValue(String typeId) { + if (MCNGPortTypes.LONG.id().equals(typeId)) { + return NodeConfigValues.longValue(0L); + } + if (MCNGPortTypes.DOUBLE.id().equals(typeId)) { + return NodeConfigValues.doubleValue(0.0); + } + if (MCNGPortTypes.STRING.id().equals(typeId)) { + return NodeConfigValues.stringValue(""); + } + if (MCNGPortTypes.BOOLEAN.id().equals(typeId)) { + return NodeConfigValues.booleanValue(false); + } + if (MCNGPortTypes.ANY.id().equals(typeId)) { + return NodeConfigValues.stringValue(""); + } + return NodeConfigValues.intValue(0); + } + + private Bounds selectionBounds(Set selection, Map positions, Map sizes) { + double minX = Double.POSITIVE_INFINITY; + double minY = Double.POSITIVE_INFINITY; + double maxX = Double.NEGATIVE_INFINITY; + double maxY = Double.NEGATIVE_INFINITY; + for (NodeId nodeId : selection) { + NodePosition position = positions.getOrDefault(nodeId, new NodePosition(0, 0)); + NodeSize size = sizes.getOrDefault(nodeId, DEFAULT_SELECTION_NODE_SIZE); + minX = Math.min(minX, position.x()); + minY = Math.min(minY, position.y()); + maxX = Math.max(maxX, position.x() + size.width()); + maxY = Math.max(maxY, position.y() + size.height()); + } + return new Bounds(minX, minY, maxX, maxY); + } + + private void persist() { + persist(document()); + } + + private void persist(GraphDocument document) { + refreshRuntime(document); + host.onDocumentChanged(document); + } + + private void cancelExecutionSilently() { + if (runningExecution != null) { + runningExecution.cancel(); + runningExecution = null; + } + lastExecutionSnapshot = emptySnapshot(); + refreshExecutionHighlights(); + } + + private void refreshExecutionHighlights() { + if (lastExecutionSnapshot.frontier().isEmpty()) { + executingVisibleNodeIds = Set.of(); + executingVisibleRerouteNodeIds = Set.of(); + return; + } + + Set visibleNodeIds = new LinkedHashSet<>(); + for (ExecutionPosition position : lastExecutionSnapshot.frontier()) { + NodeId visibleNodeId = visibleNodeFor(position); + if (visibleNodeId != null) { + visibleNodeIds.add(visibleNodeId); + } + } + executingVisibleNodeIds = Set.copyOf(visibleNodeIds); + executingVisibleRerouteNodeIds = collectExecutingVisibleReroutes(visibleNodeIds); + } + + private Set collectExecutingVisibleReroutes(Set activeVisibleNodes) { + if (activeVisibleNodes.isEmpty()) { + return Set.of(); + } + + GraphDefinition graph = currentState().graph(); + Set activeReroutes = new LinkedHashSet<>(); + ArrayList queue = new ArrayList<>(); + for (EdgeDefinition edge : graph.edges()) { + if (BuiltinNodeTypes.VALUE_PORT.equals(edge.toPortId()) + && activeVisibleNodes.contains(edge.fromNodeId()) + && isReroute(edge.toNodeId(), currentState().nodes.get(edge.toNodeId()))) { + queue.add(edge.toNodeId()); + } + if (BuiltinNodeTypes.VALUE_PORT.equals(edge.fromPortId()) + && activeVisibleNodes.contains(edge.toNodeId()) + && isReroute(edge.fromNodeId(), currentState().nodes.get(edge.fromNodeId()))) { + queue.add(edge.fromNodeId()); + } + } + + int index = 0; + while (index < queue.size()) { + NodeId rerouteNodeId = queue.get(index++); + if (!activeReroutes.add(rerouteNodeId)) { + continue; + } + for (EdgeDefinition edge : graph.edges()) { + PortRef anchor = rerouteOpposite(edge, rerouteNodeId); + if (anchor == null) { + continue; + } + NodeInstance oppositeNode = currentState().nodes.get(anchor.nodeId()); + if (isReroute(anchor.nodeId(), oppositeNode)) { + queue.add(anchor.nodeId()); + } + } + } + return Set.copyOf(activeReroutes); + } + + private NodeId visibleNodeFor(ExecutionPosition position) { + if (position.definitionPath().equals(contextPath)) { + return visibleNodeIdFromRuntimeNode(position.nodeId(), currentPlanContextPath()); + } + if (position.definitionPath().size() < contextPath.size()) { + return null; + } + for (int index = 0; index < contextPath.size(); index++) { + if (!contextPath.get(index).equals(position.definitionPath().get(index))) { + return null; + } + } + int currentCustomDepth = currentCustomDepth(); + if (position.invocationPath().size() > currentCustomDepth) { + return position.invocationPath().get(currentCustomDepth); + } + if (position.definitionPath().size() > contextPath.size()) { + String nextScopeId = position.definitionPath().get(contextPath.size()); + return containerNodeIdForSubgraph(nextScopeId); + } + return visibleNodeIdFromRuntimeNode(position.nodeId(), currentPlanContextPath()); + } + + private int currentCustomDepth() { + int depth = 0; + for (String definitionId : contextPath) { + DefinitionState definition = definitions.get(definitionId); + if (definition != null && definition.kind == DocumentNodeDefinitionKind.CUSTOM_NODE) { + depth++; + } + } + return depth; + } + + private String currentCustomDefinitionId() { + for (int index = contextPath.size() - 1; index >= 0; index--) { + DefinitionState definition = definitions.get(contextPath.get(index)); + if (definition != null && definition.kind == DocumentNodeDefinitionKind.CUSTOM_NODE) { + return definition.id; + } + } + return null; + } + + private List currentPlanContextPath() { + String customDefinitionId = currentCustomDefinitionId(); + if (customDefinitionId == null) { + return List.copyOf(contextPath); + } + int index = contextPath.indexOf(customDefinitionId); + return index < 0 ? List.of() : List.copyOf(contextPath.subList(index + 1, contextPath.size())); + } + + private NodeId containerNodeIdForSubgraph(String subgraphId) { + String typeId = DocumentNodeTypes.subgraphTypeId(subgraphId); + return currentState().nodes.values().stream() + .filter(node -> typeId.equals(node.typeId())) + .map(NodeInstance::id) + .findFirst() + .orElse(null); + } + + private NodeId visibleNodeIdFromRuntimeNode(NodeId runtimeNodeId, List planContext) { + if (planContext.isEmpty()) { + return runtimeNodeId; + } + String prefix = FLAT_NODE_PREFIX + String.join("/", planContext) + "/"; + return runtimeNodeId.value().startsWith(prefix) + ? new NodeId(runtimeNodeId.value().substring(prefix.length())) + : runtimeNodeId; + } + + private static ExecutionSnapshot emptySnapshot() { + return new ExecutionSnapshot(Map.of(), Map.of(), List.of(), List.of(), List.of(), ExecutionSessionStatus.COMPLETED); + } + + public record PendingConnection(NodeId nodeId, PortId portId, PortDirection direction) { + } + + public record Breadcrumb(String label, String definitionId) { + } + + private record EditorSnapshot( + GraphDocument document, + List contextPath, + List contextNodePath, + List selectedNodeIds, + NodeId primarySelectedNodeId + ) { + private boolean matches( + GraphDocument document, + List contextPath, + List contextNodePath, + Set selectedNodeIds, + NodeId primarySelectedNodeId + ) { + return this.document.equals(document) + && this.contextPath.equals(contextPath) + && this.contextNodePath.equals(contextNodePath) + && this.selectedNodeIds.equals(List.copyOf(selectedNodeIds)) + && Objects.equals(this.primarySelectedNodeId, primarySelectedNodeId); + } + } + + private record HistoryEntry(EditorSnapshot before, EditorSnapshot after) { + } + + private static final class MutableGraphState { + private final Map nodes = new LinkedHashMap<>(); + private final Map positions = new LinkedHashMap<>(); + private final Map sizes = new LinkedHashMap<>(); + private final List edges = new ArrayList<>(); + + private MutableGraphState() { + } + + private MutableGraphState(GraphDefinition graph, GraphLayout layout) { + load(graph, layout); + } + + private void load(GraphDefinition graph, GraphLayout layout) { + nodes.clear(); + positions.clear(); + sizes.clear(); + edges.clear(); + graph.nodes().forEach(node -> nodes.put(node.id(), node)); + positions.putAll(layout.nodePositions()); + sizes.putAll(layout.nodeSizes()); + edges.addAll(graph.edges()); + } + + private GraphDefinition graph() { + return new GraphDefinition(new ArrayList<>(nodes.values()), new ArrayList<>(edges)); + } + + private GraphLayout layout() { + return new GraphLayout(new LinkedHashMap<>(positions), new LinkedHashMap<>(sizes)); + } + } + + private static final class DefinitionState { + private final String id; + private String displayName; + private final DocumentNodeDefinitionKind kind; + private final MutableGraphState graphState; + private final Map variables = new LinkedHashMap<>(); + + private DefinitionState(String id, String displayName, DocumentNodeDefinitionKind kind, MutableGraphState graphState) { + this.id = id; + this.displayName = displayName; + this.kind = kind; + this.graphState = graphState; + } + } + + private record ParentScopeRef(String id) { + } + + private record PortIdKey(NodeId nodeId, PortId portId) { + } + + private record LocalClipboard(GraphDefinition graph, GraphLayout layout, List definitions) { + } + + private record ClipboardDefinition(String sourceId, String displayName, GraphScope scope) { + } + + private record GroupKey(NodeId nodeId, String groupId) { + } + + private record ScopedGroupKey(int depth, GroupKey groupKey) { + } + + private record PortRef(NodeId nodeId, PortId portId, PortDirection direction) { + } + + private record RerouteComponent(Set rerouteNodeIds, List anchors) { + } + + private record ScopedPortRef(int depth, PortRef portRef) { + } + + private record Bounds(double minX, double minY, double maxX, double maxY) { + private double width() { + return maxX - minX; + } + + private double centerX() { + return (minX + maxX) / 2.0; + } + + private double centerY() { + return (minY + maxY) / 2.0; + } + } + + private final class RecordingExecutionContext implements com.github.squi2rel.mcng.core.NodeExecutionContext { + @Override + public void publishDebug(NodeId nodeId, String message) { + debugMessages.add(nodeId.value() + ": " + message); + } + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphEditorTheme.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphEditorTheme.java new file mode 100644 index 0000000..b4c0e43 --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphEditorTheme.java @@ -0,0 +1,159 @@ +package com.github.squi2rel.mcng.fabric.client; + +public record GraphEditorTheme( + int canvasBackgroundColor, + int gridColor, + int nodeBodyColor, + int nodeHeaderColor, + int nodeBorderColor, + int panelBackgroundColor, + int panelBorderColor, + int primaryTextColor, + int secondaryTextColor, + int accentColor, + int controlFlowColor, + int executionColor, + int errorColor +) { + public static GraphEditorTheme classic() { + return new GraphEditorTheme( + 0xFF10151F, + 0xFF1E2430, + 0xFF1A2230, + 0xFF223047, + 0xFF4B5D78, + 0xD0101520, + 0xFF4B5D78, + 0xFFF6F7FB, + 0xFFD6DEEF, + 0xFFFFCC55, + 0xFF7AC4FF, + 0xFF7CFF8A, + 0xFFFF8C8C + ); + } + + public static GraphEditorTheme light() { + return new GraphEditorTheme( + 0xFFF4F1EA, + 0xFFD3CCBE, + 0xFFFBFAF6, + 0xFFE3DDCF, + 0xFF7E7360, + 0xEAF7F3EA, + 0xFF8C7F69, + 0xFF221C14, + 0xFF5B5044, + 0xFFB26A2A, + 0xFF4E83B6, + 0xFF37A843, + 0xFFB43C3C + ); + } + + public static GraphEditorTheme highContrast() { + return new GraphEditorTheme( + 0xFF050505, + 0xFF1E1E1E, + 0xFF111111, + 0xFF1B1B1B, + 0xFFFFFFFF, + 0xE0000000, + 0xFFFFFFFF, + 0xFFFFFFFF, + 0xFFB8B8B8, + 0xFF00E2FF, + 0xFFFFF066, + 0xFF00FF66, + 0xFFFF4B4B + ); + } + + public static GraphEditorTheme defaultTheme() { + return classic(); + } + + public GraphEditorTheme withCanvasBackgroundColor(int color) { + return copy(color, gridColor, nodeBodyColor, nodeHeaderColor, nodeBorderColor, panelBackgroundColor, panelBorderColor, primaryTextColor, secondaryTextColor, accentColor, controlFlowColor, executionColor, errorColor); + } + + public GraphEditorTheme withGridColor(int color) { + return copy(canvasBackgroundColor, color, nodeBodyColor, nodeHeaderColor, nodeBorderColor, panelBackgroundColor, panelBorderColor, primaryTextColor, secondaryTextColor, accentColor, controlFlowColor, executionColor, errorColor); + } + + public GraphEditorTheme withNodeBodyColor(int color) { + return copy(canvasBackgroundColor, gridColor, color, nodeHeaderColor, nodeBorderColor, panelBackgroundColor, panelBorderColor, primaryTextColor, secondaryTextColor, accentColor, controlFlowColor, executionColor, errorColor); + } + + public GraphEditorTheme withNodeHeaderColor(int color) { + return copy(canvasBackgroundColor, gridColor, nodeBodyColor, color, nodeBorderColor, panelBackgroundColor, panelBorderColor, primaryTextColor, secondaryTextColor, accentColor, controlFlowColor, executionColor, errorColor); + } + + public GraphEditorTheme withNodeBorderColor(int color) { + return copy(canvasBackgroundColor, gridColor, nodeBodyColor, nodeHeaderColor, color, panelBackgroundColor, panelBorderColor, primaryTextColor, secondaryTextColor, accentColor, controlFlowColor, executionColor, errorColor); + } + + public GraphEditorTheme withPanelBackgroundColor(int color) { + return copy(canvasBackgroundColor, gridColor, nodeBodyColor, nodeHeaderColor, nodeBorderColor, color, panelBorderColor, primaryTextColor, secondaryTextColor, accentColor, controlFlowColor, executionColor, errorColor); + } + + public GraphEditorTheme withPanelBorderColor(int color) { + return copy(canvasBackgroundColor, gridColor, nodeBodyColor, nodeHeaderColor, nodeBorderColor, panelBackgroundColor, color, primaryTextColor, secondaryTextColor, accentColor, controlFlowColor, executionColor, errorColor); + } + + public GraphEditorTheme withPrimaryTextColor(int color) { + return copy(canvasBackgroundColor, gridColor, nodeBodyColor, nodeHeaderColor, nodeBorderColor, panelBackgroundColor, panelBorderColor, color, secondaryTextColor, accentColor, controlFlowColor, executionColor, errorColor); + } + + public GraphEditorTheme withSecondaryTextColor(int color) { + return copy(canvasBackgroundColor, gridColor, nodeBodyColor, nodeHeaderColor, nodeBorderColor, panelBackgroundColor, panelBorderColor, primaryTextColor, color, accentColor, controlFlowColor, executionColor, errorColor); + } + + public GraphEditorTheme withAccentColor(int color) { + return copy(canvasBackgroundColor, gridColor, nodeBodyColor, nodeHeaderColor, nodeBorderColor, panelBackgroundColor, panelBorderColor, primaryTextColor, secondaryTextColor, color, controlFlowColor, executionColor, errorColor); + } + + public GraphEditorTheme withControlFlowColor(int color) { + return copy(canvasBackgroundColor, gridColor, nodeBodyColor, nodeHeaderColor, nodeBorderColor, panelBackgroundColor, panelBorderColor, primaryTextColor, secondaryTextColor, accentColor, color, executionColor, errorColor); + } + + public GraphEditorTheme withExecutionColor(int color) { + return copy(canvasBackgroundColor, gridColor, nodeBodyColor, nodeHeaderColor, nodeBorderColor, panelBackgroundColor, panelBorderColor, primaryTextColor, secondaryTextColor, accentColor, controlFlowColor, color, errorColor); + } + + public GraphEditorTheme withErrorColor(int color) { + return copy(canvasBackgroundColor, gridColor, nodeBodyColor, nodeHeaderColor, nodeBorderColor, panelBackgroundColor, panelBorderColor, primaryTextColor, secondaryTextColor, accentColor, controlFlowColor, executionColor, color); + } + + private GraphEditorTheme copy( + int canvasBackgroundColor, + int gridColor, + int nodeBodyColor, + int nodeHeaderColor, + int nodeBorderColor, + int panelBackgroundColor, + int panelBorderColor, + int primaryTextColor, + int secondaryTextColor, + int accentColor, + int controlFlowColor, + int executionColor, + int errorColor + ) { + return new GraphEditorTheme( + canvasBackgroundColor, + gridColor, + nodeBodyColor, + nodeHeaderColor, + nodeBorderColor, + panelBackgroundColor, + panelBorderColor, + primaryTextColor, + secondaryTextColor, + accentColor, + controlFlowColor, + executionColor, + errorColor + ); + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphEditorTranslations.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphEditorTranslations.java new file mode 100644 index 0000000..308f177 --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphEditorTranslations.java @@ -0,0 +1,149 @@ +package com.github.squi2rel.mcng.fabric.client; + +import com.github.squi2rel.mcng.core.DocumentNodeTypes; +import com.github.squi2rel.mcng.core.GraphError; +import com.github.squi2rel.mcng.core.GraphErrorCode; +import com.github.squi2rel.mcng.core.NodeEditorControl; +import com.github.squi2rel.mcng.core.NodeType; +import com.github.squi2rel.mcng.core.PortDefinition; + +import java.util.Locale; + +final class GraphEditorTranslations { + private static final String UI_PREFIX = "mcng.ui."; + private static final String NODE_PREFIX = "mcng.node."; + private static final String ERROR_PREFIX = "mcng.error.code."; + private static final String PORT_TYPE_PREFIX = "mcng.port_type."; + + private GraphEditorTranslations() { + } + + static String ui(GraphEditorI18n i18n, String key, String fallback, Object... args) { + return i18n.translate(UI_PREFIX + key, fallback, args); + } + + static String nodeTitle(GraphEditorI18n i18n, NodeType nodeType) { + if (isDynamicDocumentNodeType(nodeType.id())) { + return DocumentNodeTypes.readDefinitionDisplayName(nodeType); + } + return i18n.translate(nodeTitleKey(nodeType.id()), DocumentNodeTypes.readDefinitionDisplayName(nodeType)); + } + + static String portLabel(GraphEditorI18n i18n, NodeType nodeType, PortDefinition port) { + if (isDynamicDocumentNodeType(nodeType.id())) { + return port.name(); + } + return i18n.translate(portKey(nodeType.id(), port.id().value()), port.name()); + } + + static String controlLabel(GraphEditorI18n i18n, NodeType nodeType, NodeEditorControl control) { + if (DocumentNodeTypes.DEFINITION_NAME_CONTROL_KEY.equals(control.key())) { + return ui(i18n, "node.definition_name", control.label()); + } + if (isDynamicDocumentNodeType(nodeType.id())) { + return control.label(); + } + return i18n.translate(controlKey(nodeType.id(), control.key()), control.label()); + } + + static String controlOptionLabel(GraphEditorI18n i18n, NodeType nodeType, NodeEditorControl.CycleControl control, NodeEditorControl.Option option) { + if (isDynamicDocumentNodeType(nodeType.id())) { + return option.displayName(); + } + return i18n.translate(controlOptionKey(nodeType.id(), control.key(), option.id()), option.displayName()); + } + + static String paletteSection(GraphEditorI18n i18n, NodePaletteDefinition definition) { + return definition.sectionTranslationKey() == null + ? definition.sectionTitle() + : i18n.translate(definition.sectionTranslationKey(), definition.sectionTitle()); + } + + static String errorCode(GraphEditorI18n i18n, GraphErrorCode code) { + return i18n.translate(ERROR_PREFIX + sanitize(code.name()), humanize(code.name())); + } + + static String formatError(GraphEditorI18n i18n, GraphError error) { + String prefix = errorCode(i18n, error.code()); + if (error.message() == null || error.message().isBlank()) { + return prefix; + } + return prefix + ": " + error.message(); + } + + static String shortPortTypeLabel(GraphEditorI18n i18n, String typeId) { + return i18n.translate(PORT_TYPE_PREFIX + sanitize(typeId) + ".short", fallbackPortTypeLabel(typeId)); + } + + static String nodeTitleKey(String nodeTypeId) { + return nodePrefix(nodeTypeId) + ".title"; + } + + static String portKey(String nodeTypeId, String portId) { + return nodePrefix(nodeTypeId) + ".port." + sanitize(portId); + } + + static String controlKey(String nodeTypeId, String controlKey) { + return nodePrefix(nodeTypeId) + ".control." + sanitize(controlKey); + } + + static String controlOptionKey(String nodeTypeId, String controlKey, String optionId) { + return controlKey(nodeTypeId, controlKey) + ".option." + sanitize(optionId); + } + + static String sanitize(String raw) { + if (raw == null || raw.isBlank()) { + return "unknown"; + } + String lower = raw.toLowerCase(Locale.ROOT); + StringBuilder builder = new StringBuilder(lower.length()); + boolean dotPending = false; + for (int index = 0; index < lower.length(); index++) { + char current = lower.charAt(index); + if ((current >= 'a' && current <= 'z') || (current >= '0' && current <= '9')) { + if (dotPending && !builder.isEmpty()) { + builder.append('.'); + } + builder.append(current); + dotPending = false; + } else { + dotPending = true; + } + } + return builder.isEmpty() ? "unknown" : builder.toString(); + } + + private static boolean isDynamicDocumentNodeType(String nodeTypeId) { + return DocumentNodeTypes.isDefinitionType(nodeTypeId) || DocumentNodeTypes.isSubgraphType(nodeTypeId); + } + + private static String nodePrefix(String nodeTypeId) { + return NODE_PREFIX + sanitize(nodeTypeId); + } + + private static String humanize(String raw) { + String[] words = raw.split("_+"); + StringBuilder builder = new StringBuilder(raw.length()); + for (String word : words) { + if (word.isBlank()) { + continue; + } + if (!builder.isEmpty()) { + builder.append(' '); + } + builder.append(Character.toUpperCase(word.charAt(0))); + if (word.length() > 1) { + builder.append(word.substring(1).toLowerCase(Locale.ROOT)); + } + } + return builder.isEmpty() ? raw : builder.toString(); + } + + private static String fallbackPortTypeLabel(String typeId) { + if (typeId == null || typeId.isBlank()) { + return "unknown"; + } + int separator = Math.max(typeId.lastIndexOf(':'), typeId.lastIndexOf('/')); + return separator >= 0 && separator < typeId.length() - 1 ? typeId.substring(separator + 1) : typeId; + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphEditorUiConfig.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphEditorUiConfig.java new file mode 100644 index 0000000..dc639fd --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphEditorUiConfig.java @@ -0,0 +1,43 @@ +package com.github.squi2rel.mcng.fabric.client; + +public record GraphEditorUiConfig( + GraphEditorTheme theme, + EdgeStyle edgeStyle, + NodeCornerStyle nodeCornerStyle, + PortShape portShape +) { + public GraphEditorUiConfig { + if (theme == null) { + throw new IllegalArgumentException("theme must not be null"); + } + if (edgeStyle == null) { + throw new IllegalArgumentException("edgeStyle must not be null"); + } + if (nodeCornerStyle == null) { + throw new IllegalArgumentException("nodeCornerStyle must not be null"); + } + if (portShape == null) { + throw new IllegalArgumentException("portShape must not be null"); + } + } + + public static GraphEditorUiConfig defaultConfig() { + return new GraphEditorUiConfig(GraphEditorTheme.defaultTheme(), EdgeStyle.CURVE, NodeCornerStyle.ROUNDED, PortShape.CIRCLE); + } + + public GraphEditorUiConfig withTheme(GraphEditorTheme theme) { + return new GraphEditorUiConfig(theme, edgeStyle, nodeCornerStyle, portShape); + } + + public GraphEditorUiConfig withEdgeStyle(EdgeStyle edgeStyle) { + return new GraphEditorUiConfig(theme, edgeStyle, nodeCornerStyle, portShape); + } + + public GraphEditorUiConfig withNodeCornerStyle(NodeCornerStyle nodeCornerStyle) { + return new GraphEditorUiConfig(theme, edgeStyle, nodeCornerStyle, portShape); + } + + public GraphEditorUiConfig withPortShape(PortShape portShape) { + return new GraphEditorUiConfig(theme, edgeStyle, nodeCornerStyle, portShape); + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphFileDialogRequest.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphFileDialogRequest.java new file mode 100644 index 0000000..39093fa --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphFileDialogRequest.java @@ -0,0 +1,27 @@ +package com.github.squi2rel.mcng.fabric.client; + +import java.util.List; +import java.util.Objects; + +public record GraphFileDialogRequest( + String title, + List extensions, + String initialPath +) { + public GraphFileDialogRequest { + if (title == null || title.isBlank()) { + throw new IllegalArgumentException("title must not be blank"); + } + Objects.requireNonNull(extensions, "extensions"); + if (extensions.isEmpty()) { + throw new IllegalArgumentException("extensions must not be empty"); + } + extensions = List.copyOf(extensions); + for (String extension : extensions) { + if (extension == null || extension.isBlank()) { + throw new IllegalArgumentException("extensions must not contain blanks"); + } + } + initialPath = initialPath == null ? "" : initialPath; + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphInputModifiers.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphInputModifiers.java new file mode 100644 index 0000000..404b1b6 --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphInputModifiers.java @@ -0,0 +1,24 @@ +package com.github.squi2rel.mcng.fabric.client; + +import com.mojang.blaze3d.platform.InputConstants; +import com.mojang.blaze3d.platform.Window; +import net.minecraft.client.Minecraft; + +final class GraphInputModifiers { + private GraphInputModifiers() { + } + + static boolean shiftDown() { + try { + Minecraft client = Minecraft.getInstance(); + if (client == null) { + return false; + } + Window window = client.getWindow(); + return InputConstants.isKeyDown(window, InputConstants.KEY_LSHIFT) + || InputConstants.isKeyDown(window, InputConstants.KEY_RSHIFT); + } catch (RuntimeException ignored) { + return false; + } + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphInputText.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphInputText.java new file mode 100644 index 0000000..13d75b6 --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphInputText.java @@ -0,0 +1,20 @@ +package com.github.squi2rel.mcng.fabric.client; + +import com.mojang.blaze3d.platform.InputConstants; + +final class GraphInputText { + private GraphInputText() { + } + + static String key(int keyCode) { + return InputConstants.Type.KEYSYM.getOrCreate(keyCode).getDisplayName().getString(); + } + + static String mouse(int button) { + return InputConstants.Type.MOUSE.getOrCreate(button).getDisplayName().getString(); + } + + static String shortcut(String modifier, int keyCode) { + return modifier + "+" + key(keyCode); + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphInteractionController.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphInteractionController.java new file mode 100644 index 0000000..9483373 --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphInteractionController.java @@ -0,0 +1,449 @@ +package com.github.squi2rel.mcng.fabric.client; + +import com.github.squi2rel.mcng.core.NodeId; +import com.github.squi2rel.mcng.core.NodePosition; +import com.github.squi2rel.mcng.core.NodeSize; +import com.github.squi2rel.mcng.core.PortDirection; +import com.github.squi2rel.mcng.fabric.client.NodeWidget.PortWidget; + +import java.util.LinkedHashMap; +import java.util.Map; + +public final class GraphInteractionController { + private final Map draggingNodes = new LinkedHashMap<>(); + private ResizeOperation resizeOperation; + private NodeId collapseSelectionNodeId; + private boolean draggedNodeSelection; + + private double dragAnchorWorldX; + private double dragAnchorWorldY; + private boolean panning; + private boolean selecting; + private boolean additiveSelection; + private double selectionStartWorldX; + private double selectionStartWorldY; + private double selectionCurrentWorldX; + private double selectionCurrentWorldY; + + public boolean mouseClicked(GraphCanvasComponent canvas, double mouseX, double mouseY, int button) { + PortWidget port = canvas.portAtLocal(mouseX, mouseY); + if (port != null) { + if (button == 1) { + canvas.session().clearPortConnections(port.nodeId(), port.definition().id(), port.definition().direction()); + return true; + } + canvas.session().toggleConnectionCandidate(port.nodeId(), port.definition().id(), port.definition().direction(), port.side()); + return true; + } + + GraphCanvasComponent.ResizeTarget resizeTarget = canvas.resizeTargetAtLocal(mouseX, mouseY); + if (resizeTarget != null && button == 0) { + NodeWidget widget = resizeTarget.widget(); + if (!canvas.session().isSelected(widget.node().id())) { + canvas.session().selectNode(widget.node().id()); + } + canvas.session().beginCompositeEdit(); + NodeWidget.PortSide fixedRerouteSide = widget.compactReroute() ? fixedReroutePortSide(widget.rerouteOrientation(), resizeTarget.direction()) : null; + PortWidget fixedReroutePort = fixedRerouteSide == null ? null : reroutePortOnSide(widget, fixedRerouteSide); + resizeOperation = new ResizeOperation( + widget.node().id(), + resizeTarget.direction(), + canvas.session().positions().getOrDefault(widget.node().id(), new NodePosition(widget.x(), widget.y())), + new NodeSize(widget.width(), widget.height()), + widget.minWidth(), + widget.minHeight(), + mouseX, + mouseY, + canvas.viewport().toWorldX(mouseX), + canvas.viewport().toWorldY(mouseY), + widget.compactReroute(), + widget.rerouteOrientation(), + widget.edgePadding(), + fixedReroutePort == null ? null : fixedReroutePort.definition().direction(), + fixedReroutePort == null ? 0.0 : fixedReroutePort.centerX(), + fixedReroutePort == null ? 0.0 : fixedReroutePort.centerY() + ); + draggingNodes.clear(); + collapseSelectionNodeId = null; + draggedNodeSelection = false; + return true; + } + + NodeWidget node = canvas.nodeAtLocal(mouseX, mouseY); + if (node != null) { + if (button == 0) { + if (shiftDown()) { + canvas.session().toggleNodeSelection(node.node().id()); + collapseSelectionNodeId = null; + } else if (!canvas.session().isSelected(node.node().id())) { + canvas.session().selectNode(node.node().id()); + collapseSelectionNodeId = null; + } else { + collapseSelectionNodeId = canvas.session().selectedNodeIds().size() > 1 ? node.node().id() : null; + } + draggingNodes.clear(); + for (NodeId nodeId : canvas.session().selectedNodeIds()) { + draggingNodes.put(nodeId, canvas.session().positions().getOrDefault(nodeId, new NodePosition(0, 0))); + } + canvas.session().beginCompositeEdit(); + dragAnchorWorldX = canvas.viewport().toWorldX(mouseX); + dragAnchorWorldY = canvas.viewport().toWorldY(mouseY); + draggedNodeSelection = false; + } else { + collapseSelectionNodeId = null; + canvas.session().selectNode(node.node().id()); + } + return true; + } + + if (button == 2) { + collapseSelectionNodeId = null; + panning = true; + return true; + } + if (button == 0) { + collapseSelectionNodeId = null; + additiveSelection = shiftDown(); + if (!additiveSelection) { + canvas.session().clearSelection(); + } + selecting = true; + selectionStartWorldX = canvas.viewport().toWorldX(mouseX); + selectionStartWorldY = canvas.viewport().toWorldY(mouseY); + selectionCurrentWorldX = selectionStartWorldX; + selectionCurrentWorldY = selectionStartWorldY; + return true; + } + + return false; + } + + public boolean mouseDragged(GraphCanvasComponent canvas, double mouseX, double mouseY, int button, double deltaX, double deltaY) { + if (resizeOperation != null) { + if (resizeOperation.compactReroute()) { + return dragCompactReroute(canvas, mouseX, mouseY); + } + return dragRegularResize(canvas, mouseX, mouseY); + } + if (!draggingNodes.isEmpty()) { + double currentWorldX = canvas.viewport().toWorldX(mouseX); + double currentWorldY = canvas.viewport().toWorldY(mouseY); + double offsetX = currentWorldX - dragAnchorWorldX; + double offsetY = currentWorldY - dragAnchorWorldY; + if (offsetX != 0.0 || offsetY != 0.0) { + draggedNodeSelection = true; + } + + Map updated = new LinkedHashMap<>(); + draggingNodes.forEach((nodeId, position) -> updated.put(nodeId, new NodePosition(position.x() + offsetX, position.y() + offsetY))); + canvas.session().moveNodes(updated); + return true; + } + if (panning) { + canvas.viewport().pan(deltaX, deltaY); + return true; + } + if (selecting) { + selectionCurrentWorldX = canvas.viewport().toWorldX(mouseX); + selectionCurrentWorldY = canvas.viewport().toWorldY(mouseY); + return true; + } + return false; + } + + private boolean dragRegularResize(GraphCanvasComponent canvas, double mouseX, double mouseY) { + double currentWorldX = canvas.viewport().toWorldX(mouseX); + double currentWorldY = canvas.viewport().toWorldY(mouseY); + double offsetX = currentWorldX - resizeOperation.anchorWorldX(); + double offsetY = currentWorldY - resizeOperation.anchorWorldY(); + double left = resizeOperation.startPosition().x(); + double top = resizeOperation.startPosition().y(); + double right = left + resizeOperation.startSize().width(); + double bottom = top + resizeOperation.startSize().height(); + + if (resizeOperation.direction().includesLeft()) { + left = Math.min(right - resizeOperation.minWidth(), left + offsetX); + } + if (resizeOperation.direction().includesRight()) { + right = Math.max(left + resizeOperation.minWidth(), right + offsetX); + } + if (resizeOperation.direction().includesTop()) { + top = Math.min(bottom - resizeOperation.minHeight(), top + offsetY); + } + if (resizeOperation.direction().includesBottom()) { + bottom = Math.max(top + resizeOperation.minHeight(), bottom + offsetY); + } + + canvas.session().resizeNode( + resizeOperation.nodeId(), + new NodePosition(left, top), + new NodeSize(Math.max(resizeOperation.minWidth(), (int) Math.round(right - left)), Math.max(resizeOperation.minHeight(), (int) Math.round(bottom - top))) + ); + return true; + } + + private boolean dragCompactReroute(GraphCanvasComponent canvas, double mouseX, double mouseY) { + double mouseWorldX = canvas.viewport().toWorldX(mouseX); + double mouseWorldY = canvas.viewport().toWorldY(mouseY); + boolean vertical = activeRerouteOrientation(mouseWorldX, mouseWorldY); + NodeWidget.PortSide draggedSide = rerouteDraggedSide(mouseWorldX, mouseWorldY, vertical); + NodeWidget.PortSide fixedSide = draggedSide.opposite(); + double fixedPortX = resizeOperation.fixedPortWorldX(); + double fixedPortY = resizeOperation.fixedPortWorldY(); + int thickness = NodeWidget.compactRerouteThickness(); + int minimumLength = NodeWidget.compactRerouteMinimumLength(); + int minimumSpan = Math.max(0, minimumLength - (resizeOperation.portInset() * 2)); + double left; + double top; + NodeSize size; + + if (vertical) { + double draggedPortY = draggedSide == NodeWidget.PortSide.BOTTOM + ? Math.max(mouseWorldY, fixedPortY + minimumSpan) + : Math.min(mouseWorldY, fixedPortY - minimumSpan); + left = fixedPortX - (thickness / 2.0); + top = fixedSide == NodeWidget.PortSide.TOP + ? fixedPortY - resizeOperation.portInset() + : draggedPortY - resizeOperation.portInset(); + double bottom = fixedSide == NodeWidget.PortSide.BOTTOM + ? fixedPortY + resizeOperation.portInset() + : draggedPortY + resizeOperation.portInset(); + size = new NodeSize(thickness, Math.max(minimumLength, (int) Math.round(bottom - top))); + } else { + double draggedPortX = draggedSide == NodeWidget.PortSide.RIGHT + ? Math.max(mouseWorldX, fixedPortX + minimumSpan) + : Math.min(mouseWorldX, fixedPortX - minimumSpan); + left = fixedSide == NodeWidget.PortSide.LEFT + ? fixedPortX - resizeOperation.portInset() + : draggedPortX - resizeOperation.portInset(); + top = fixedPortY - (thickness / 2.0); + double right = fixedSide == NodeWidget.PortSide.RIGHT + ? fixedPortX + resizeOperation.portInset() + : draggedPortX + resizeOperation.portInset(); + size = new NodeSize(Math.max(minimumLength, (int) Math.round(right - left)), thickness); + } + + RerouteOrientation orientation = RerouteOrientation.fromFixedPort(fixedSide, resizeOperation.fixedPortDirection()); + canvas.session().resizeReroute(resizeOperation.nodeId(), new NodePosition(left, top), size, orientation); + return true; + } + + private boolean activeRerouteOrientation(double mouseWorldX, double mouseWorldY) { + if (!resizeOperation.direction().isCorner()) { + return resizeOperation.startOrientation().vertical(); + } + double deltaX = mouseWorldX - resizeOperation.fixedPortWorldX(); + double deltaY = mouseWorldY - resizeOperation.fixedPortWorldY(); + return Math.abs(deltaY) > Math.abs(deltaX); + } + + private NodeWidget.PortSide rerouteDraggedSide(double mouseWorldX, double mouseWorldY, boolean vertical) { + if (vertical) { + return mouseWorldY >= resizeOperation.fixedPortWorldY() ? NodeWidget.PortSide.BOTTOM : NodeWidget.PortSide.TOP; + } + return mouseWorldX >= resizeOperation.fixedPortWorldX() ? NodeWidget.PortSide.RIGHT : NodeWidget.PortSide.LEFT; + } + + private static NodeWidget.PortSide fixedReroutePortSide(RerouteOrientation orientation, ResizeDirection direction) { + if (orientation.vertical()) { + return direction.includesTop() ? NodeWidget.PortSide.BOTTOM : NodeWidget.PortSide.TOP; + } + return direction.includesLeft() ? NodeWidget.PortSide.RIGHT : NodeWidget.PortSide.LEFT; + } + + private static PortWidget reroutePortOnSide(NodeWidget widget, NodeWidget.PortSide side) { + return widget.ports().stream() + .filter(port -> port.side() == side) + .findFirst() + .orElse(null); + } + + public boolean mouseReleased(GraphCanvasComponent canvas, double mouseX, double mouseY, int button) { + if (button == 0) { + if (resizeOperation != null) { + resizeOperation = null; + collapseSelectionNodeId = null; + draggedNodeSelection = false; + canvas.session().endCompositeEdit(); + return true; + } + if (!draggingNodes.isEmpty()) { + if (!draggedNodeSelection && collapseSelectionNodeId != null) { + canvas.session().selectNode(collapseSelectionNodeId); + } + draggingNodes.clear(); + collapseSelectionNodeId = null; + draggedNodeSelection = false; + canvas.session().endCompositeEdit(); + return true; + } + if (selecting) { + selecting = false; + collapseSelectionNodeId = null; + draggedNodeSelection = false; + canvas.session().selectNodes( + canvas.nodesInRect( + Math.min(selectionStartWorldX, selectionCurrentWorldX), + Math.min(selectionStartWorldY, selectionCurrentWorldY), + Math.max(selectionStartWorldX, selectionCurrentWorldX), + Math.max(selectionStartWorldY, selectionCurrentWorldY) + ), + additiveSelection + ); + return true; + } + } + if (button == 2) { + panning = false; + return true; + } + return false; + } + + public boolean mouseScrolled(GraphCanvasComponent canvas, double mouseX, double mouseY, double horizontalAmount, double verticalAmount) { + canvas.viewport().zoomAt(verticalAmount * 0.1, mouseX, mouseY); + return true; + } + + public SelectionBox selectionBox() { + if (!selecting) { + return null; + } + return new SelectionBox( + Math.min(selectionStartWorldX, selectionCurrentWorldX), + Math.min(selectionStartWorldY, selectionCurrentWorldY), + Math.max(selectionStartWorldX, selectionCurrentWorldX), + Math.max(selectionStartWorldY, selectionCurrentWorldY) + ); + } + + public ResizeDirection activeResizeDirection() { + return resizeOperation == null ? null : resizeOperation.direction(); + } + + public record SelectionBox(double minX, double minY, double maxX, double maxY) { + } + + private static final class ResizeOperation { + private final NodeId nodeId; + private final ResizeDirection direction; + private final NodePosition startPosition; + private final NodeSize startSize; + private final int minWidth; + private final int minHeight; + private final double anchorLocalX; + private final double anchorLocalY; + private final double anchorWorldX; + private final double anchorWorldY; + private final boolean compactReroute; + private final RerouteOrientation startOrientation; + private final int portInset; + private final PortDirection fixedPortDirection; + private final double fixedPortWorldX; + private final double fixedPortWorldY; + + private ResizeOperation( + NodeId nodeId, + ResizeDirection direction, + NodePosition startPosition, + NodeSize startSize, + int minWidth, + int minHeight, + double anchorLocalX, + double anchorLocalY, + double anchorWorldX, + double anchorWorldY, + boolean compactReroute, + RerouteOrientation startOrientation, + int portInset, + PortDirection fixedPortDirection, + double fixedPortWorldX, + double fixedPortWorldY + ) { + this.nodeId = nodeId; + this.direction = direction; + this.startPosition = startPosition; + this.startSize = startSize; + this.minWidth = minWidth; + this.minHeight = minHeight; + this.anchorLocalX = anchorLocalX; + this.anchorLocalY = anchorLocalY; + this.anchorWorldX = anchorWorldX; + this.anchorWorldY = anchorWorldY; + this.compactReroute = compactReroute; + this.startOrientation = startOrientation; + this.portInset = portInset; + this.fixedPortDirection = fixedPortDirection; + this.fixedPortWorldX = fixedPortWorldX; + this.fixedPortWorldY = fixedPortWorldY; + } + + private NodeId nodeId() { + return nodeId; + } + + private ResizeDirection direction() { + return direction; + } + + private NodePosition startPosition() { + return startPosition; + } + + private NodeSize startSize() { + return startSize; + } + + private int minWidth() { + return minWidth; + } + + private int minHeight() { + return minHeight; + } + + private double anchorLocalX() { + return anchorLocalX; + } + + private double anchorLocalY() { + return anchorLocalY; + } + + private double anchorWorldX() { + return anchorWorldX; + } + + private double anchorWorldY() { + return anchorWorldY; + } + + private boolean compactReroute() { + return compactReroute; + } + + private RerouteOrientation startOrientation() { + return startOrientation; + } + + private int portInset() { + return portInset; + } + + private PortDirection fixedPortDirection() { + return fixedPortDirection; + } + + private double fixedPortWorldX() { + return fixedPortWorldX; + } + + private double fixedPortWorldY() { + return fixedPortWorldY; + } + } + + private static boolean shiftDown() { + return GraphInputModifiers.shiftDown(); + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphTextFieldComponent.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphTextFieldComponent.java new file mode 100644 index 0000000..ce49847 --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphTextFieldComponent.java @@ -0,0 +1,227 @@ +package com.github.squi2rel.mcng.fabric.client; + +import org.lwjgl.glfw.GLFW; + +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Supplier; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphicsExtractor; + +final class GraphTextFieldComponent { + private final Supplier clipboardReader; + private final Consumer clipboardWriter; + private final Consumer changedListener; + private final Supplier placeholderSupplier; + + private GraphTextInputState state = new GraphTextInputState(""); + private NodeWidget.Bounds bounds = new NodeWidget.Bounds(0, 0, 0, 0); + private boolean focused; + private boolean draggingPointer; + private long lastPointerDownAt; + private int lastPointerIndex; + + GraphTextFieldComponent(String placeholder, Supplier clipboardReader, Consumer clipboardWriter, Consumer changedListener) { + this(() -> placeholder, clipboardReader, clipboardWriter, changedListener); + } + + GraphTextFieldComponent(Supplier placeholderSupplier, Supplier clipboardReader, Consumer clipboardWriter, Consumer changedListener) { + this.placeholderSupplier = Objects.requireNonNull(placeholderSupplier, "placeholderSupplier"); + this.clipboardReader = Objects.requireNonNull(clipboardReader, "clipboardReader"); + this.clipboardWriter = Objects.requireNonNull(clipboardWriter, "clipboardWriter"); + this.changedListener = Objects.requireNonNull(changedListener, "changedListener"); + } + + void setBounds(NodeWidget.Bounds bounds) { + this.bounds = Objects.requireNonNull(bounds, "bounds"); + } + + NodeWidget.Bounds bounds() { + return bounds; + } + + boolean contains(double mouseX, double mouseY) { + return bounds.contains(mouseX, mouseY); + } + + void setText(String text) { + state = new GraphTextInputState(text); + notifyChanged(); + } + + String text() { + return state.text(); + } + + boolean focused() { + return focused; + } + + void setFocused(boolean focused) { + this.focused = focused; + if (!focused) { + draggingPointer = false; + } + } + + void render(GuiGraphicsExtractor context, Font textRenderer, GraphEditorTheme theme, GraphEditorUiConfig uiConfig) { + GraphTextInputRenderer.renderFrame(context, bounds, theme, uiConfig, focused); + String placeholder = placeholderSupplier.get(); + int scissorLeft = bounds.x() + GraphTextInputRenderer.CONTENT_PADDING_X; + int scissorTop = bounds.y() + GraphTextInputRenderer.CONTENT_PADDING_Y; + int scissorRight = bounds.x() + bounds.width() - GraphTextInputRenderer.CONTENT_PADDING_X; + int scissorBottom = bounds.y() + bounds.height() - GraphTextInputRenderer.CONTENT_PADDING_Y; + context.enableScissor(scissorLeft, scissorTop, scissorRight, scissorBottom); + try { + if (!state.text().isEmpty()) { + GraphTextInputRenderer.renderContent(context, textRenderer, bounds, state, theme, focused); + } else { + int baselineY = bounds.y() + Math.max(2, (bounds.height() - textRenderer.lineHeight) / 2); + context.text(textRenderer, placeholder, bounds.x() + GraphTextInputRenderer.CONTENT_PADDING_X, baselineY, theme.secondaryTextColor(), false); + if (focused && (System.currentTimeMillis() / 530L) % 2L == 0L) { + int cursorX = bounds.x() + GraphTextInputRenderer.CONTENT_PADDING_X; + context.fill(cursorX, bounds.y() + GraphTextInputRenderer.CONTENT_PADDING_Y, cursorX + 1, bounds.y() + bounds.height() - GraphTextInputRenderer.CONTENT_PADDING_Y, theme.primaryTextColor()); + } + } + } finally { + context.disableScissor(); + } + } + + boolean mouseClicked(double mouseX, double mouseY, int button, Font textRenderer) { + if (button != 0) { + return false; + } + if (!bounds.contains(mouseX, mouseY)) { + setFocused(false); + return false; + } + setFocused(true); + handlePointerDown(textRenderer, mouseX, System.currentTimeMillis()); + return true; + } + + boolean mouseDragged(double mouseX, int button, Font textRenderer) { + if (!focused || button != 0 || !draggingPointer) { + return false; + } + state.setCursor(indexForScreenX(textRenderer, mouseX), true); + ensureCursorVisible(textRenderer); + return true; + } + + boolean mouseReleased(int button) { + if (button != 0) { + return false; + } + draggingPointer = false; + return focused; + } + + boolean keyPressed(int keyCode, int scanCode, int modifiers, Font textRenderer) { + if (!focused) { + return false; + } + boolean controlDown = (modifiers & GLFW.GLFW_MOD_CONTROL) != 0; + boolean shiftDown = (modifiers & GLFW.GLFW_MOD_SHIFT) != 0; + if (keyCode == GLFW.GLFW_KEY_ESCAPE) { + setFocused(false); + return true; + } + if (controlDown) { + switch (keyCode) { + case GLFW.GLFW_KEY_Z -> { + boolean changed = shiftDown ? state.redo() : state.undo(); + if (changed) { + notifyChanged(); + ensureCursorVisible(textRenderer); + } + return true; + } + case GLFW.GLFW_KEY_A -> { + state.selectAll(); + ensureCursorVisible(textRenderer); + return true; + } + case GLFW.GLFW_KEY_C -> { + clipboardWriter.accept(state.selectedText()); + return true; + } + case GLFW.GLFW_KEY_X -> { + clipboardWriter.accept(state.selectedText()); + state.insert(""); + notifyChanged(); + ensureCursorVisible(textRenderer); + return true; + } + case GLFW.GLFW_KEY_V -> { + state.insert(clipboardReader.get()); + notifyChanged(); + ensureCursorVisible(textRenderer); + return true; + } + default -> { + } + } + } + switch (keyCode) { + case GLFW.GLFW_KEY_LEFT -> state.moveLeft(controlDown, shiftDown); + case GLFW.GLFW_KEY_RIGHT -> state.moveRight(controlDown, shiftDown); + case GLFW.GLFW_KEY_HOME -> state.moveHome(shiftDown); + case GLFW.GLFW_KEY_END -> state.moveEnd(shiftDown); + case GLFW.GLFW_KEY_BACKSPACE -> { + state.backspace(controlDown); + notifyChanged(); + } + case GLFW.GLFW_KEY_DELETE -> { + state.delete(controlDown); + notifyChanged(); + } + default -> { + return false; + } + } + ensureCursorVisible(textRenderer); + return true; + } + + boolean charTyped(char chr, int modifiers, Font textRenderer) { + if (!focused) { + return false; + } + if ((modifiers & (GLFW.GLFW_MOD_CONTROL | GLFW.GLFW_MOD_ALT)) != 0 || Character.isISOControl(chr)) { + return false; + } + state.insert(String.valueOf(chr)); + notifyChanged(); + ensureCursorVisible(textRenderer); + return true; + } + + private void handlePointerDown(Font textRenderer, double screenX, long timeMs) { + int index = indexForScreenX(textRenderer, screenX); + if ((timeMs - lastPointerDownAt) <= 250L && Math.abs(index - lastPointerIndex) <= 1) { + state.selectWordAt(index); + draggingPointer = false; + } else { + state.setCursor(index, false); + draggingPointer = true; + } + lastPointerDownAt = timeMs; + lastPointerIndex = index; + ensureCursorVisible(textRenderer); + } + + private int indexForScreenX(Font textRenderer, double screenX) { + double localX = screenX - (bounds.x() + GraphTextInputRenderer.CONTENT_PADDING_X) + state.scrollX(); + return state.indexForX(textRenderer, localX); + } + + private void ensureCursorVisible(Font textRenderer) { + state.ensureCursorVisible(textRenderer, Math.max(1, bounds.width() - (GraphTextInputRenderer.CONTENT_PADDING_X * 2))); + } + + private void notifyChanged() { + changedListener.accept(state.text()); + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphTextInputRenderer.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphTextInputRenderer.java new file mode 100644 index 0000000..2f76807 --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphTextInputRenderer.java @@ -0,0 +1,121 @@ +package com.github.squi2rel.mcng.fabric.client; + +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphicsExtractor; + +final class GraphTextInputRenderer { + static final int CONTENT_PADDING_X = 4; + static final int CONTENT_PADDING_Y = 2; + + private GraphTextInputRenderer() { + } + + static void render( + GuiGraphicsExtractor context, + Font textRenderer, + NodeWidget.Bounds bounds, + GraphTextInputState state, + GraphEditorTheme theme, + GraphEditorUiConfig uiConfig, + boolean focused + ) { + renderFrame(context, bounds, theme, uiConfig, focused); + renderContent(context, textRenderer, bounds, state, theme, focused); + } + + static void renderFrame( + GuiGraphicsExtractor context, + NodeWidget.Bounds bounds, + GraphEditorTheme theme, + GraphEditorUiConfig uiConfig + ) { + renderFrame(context, bounds, theme, uiConfig, true); + } + + static void renderFrame( + GuiGraphicsExtractor context, + NodeWidget.Bounds bounds, + GraphEditorTheme theme, + GraphEditorUiConfig uiConfig, + boolean focused + ) { + EditorStyleRenderer.drawBox( + context, + bounds.x(), + bounds.y(), + bounds.width(), + bounds.height(), + EditorStyleRenderer.darken(theme.nodeBodyColor(), 0.02f), + focused ? theme.accentColor() : theme.panelBorderColor(), + uiConfig + ); + } + + static void renderContent( + GuiGraphicsExtractor context, + Font textRenderer, + NodeWidget.Bounds bounds, + GraphTextInputState state, + GraphEditorTheme theme, + boolean focused + ) { + int innerX = bounds.x() + CONTENT_PADDING_X; + int innerWidth = Math.max(1, bounds.width() - (CONTENT_PADDING_X * 2)); + int baselineY = bounds.y() + Math.max(2, (bounds.height() - textRenderer.lineHeight) / 2); + String text = state.text(); + VisibleTextSlice visible = visibleSlice(textRenderer, state, innerWidth); + int prefixWidth = textRenderer.width(text.substring(0, visible.start())); + int textX = innerX + prefixWidth - state.scrollX(); + + if (focused && state.hasSelection()) { + int selectionStartX = innerX + textRenderer.width(text.substring(0, state.selectionStart())) - state.scrollX(); + int selectionWidth = textRenderer.width(text.substring(state.selectionStart(), state.selectionEnd())); + context.fill( + selectionStartX, + bounds.y() + CONTENT_PADDING_Y, + selectionStartX + selectionWidth, + bounds.y() + bounds.height() - CONTENT_PADDING_Y, + EditorStyleRenderer.blend(theme.accentColor(), theme.nodeBodyColor(), 0.24f) + ); + } + + context.text(textRenderer, visible.text(), textX, baselineY, theme.primaryTextColor(), false); + if (focused && (System.currentTimeMillis() / 530L) % 2L == 0L) { + int cursorX = innerX + textRenderer.width(text.substring(0, state.cursor())) - state.scrollX(); + context.fill(cursorX, bounds.y() + CONTENT_PADDING_Y, cursorX + 1, bounds.y() + bounds.height() - CONTENT_PADDING_Y, theme.primaryTextColor()); + } + } + + private static VisibleTextSlice visibleSlice(Font textRenderer, GraphTextInputState state, int innerWidth) { + String text = state.text(); + if (text.isEmpty()) { + return new VisibleTextSlice(0, 0, ""); + } + + int start = 0; + while (start < text.length()) { + int nextWidth = textRenderer.width(text.substring(0, start + 1)); + if (nextWidth > state.scrollX()) { + break; + } + start++; + } + + int end = start; + int visibleRight = state.scrollX() + innerWidth; + while (end < text.length()) { + int nextWidth = textRenderer.width(text.substring(0, end + 1)); + if (nextWidth > visibleRight) { + break; + } + end++; + } + + start = Math.max(0, start - 1); + end = Math.min(text.length(), Math.max(start, end + 1)); + return new VisibleTextSlice(start, end, text.substring(start, end)); + } + + private record VisibleTextSlice(int start, int end, String text) { + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphTextInputState.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphTextInputState.java new file mode 100644 index 0000000..94bd9ee --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphTextInputState.java @@ -0,0 +1,294 @@ +package com.github.squi2rel.mcng.fabric.client; + +import java.util.ArrayDeque; +import java.util.Deque; +import net.minecraft.client.gui.Font; + +final class GraphTextInputState { + private static final int MAX_LENGTH = 128; + + private String text; + private int cursor; + private int anchor; + private int scrollX; + private final Deque undoStack = new ArrayDeque<>(); + private final Deque redoStack = new ArrayDeque<>(); + + GraphTextInputState(String initialText) { + this.text = initialText == null ? "" : sanitize(initialText); + this.cursor = this.text.length(); + this.anchor = this.cursor; + } + + String text() { + return text; + } + + int cursor() { + return cursor; + } + + int scrollX() { + return scrollX; + } + + boolean hasSelection() { + return cursor != anchor; + } + + int selectionStart() { + return Math.min(cursor, anchor); + } + + int selectionEnd() { + return Math.max(cursor, anchor); + } + + String selectedText() { + return hasSelection() ? text.substring(selectionStart(), selectionEnd()) : ""; + } + + boolean canUndo() { + return !undoStack.isEmpty(); + } + + boolean canRedo() { + return !redoStack.isEmpty(); + } + + boolean undo() { + Snapshot snapshot = undoStack.pollLast(); + if (snapshot == null) { + return false; + } + redoStack.addLast(snapshot()); + restore(snapshot); + return true; + } + + boolean redo() { + Snapshot snapshot = redoStack.pollLast(); + if (snapshot == null) { + return false; + } + undoStack.addLast(snapshot()); + restore(snapshot); + return true; + } + + void setCursor(int index, boolean extendSelection) { + cursor = clampIndex(index); + if (!extendSelection) { + anchor = cursor; + } + } + + void selectRange(int start, int end) { + anchor = clampIndex(start); + cursor = clampIndex(end); + } + + void selectAll() { + anchor = 0; + cursor = text.length(); + } + + void insert(String value) { + replaceSelection(value); + } + + void backspace(boolean byWord) { + if (hasSelection()) { + replaceSelection(""); + return; + } + if (cursor <= 0) { + return; + } + int start = byWord ? previousWordBoundary(cursor) : cursor - 1; + replaceRange(start, cursor, ""); + } + + void delete(boolean byWord) { + if (hasSelection()) { + replaceSelection(""); + return; + } + if (cursor >= text.length()) { + return; + } + int end = byWord ? nextWordBoundary(cursor) : cursor + 1; + replaceRange(cursor, end, ""); + } + + void moveLeft(boolean byWord, boolean extendSelection) { + if (!extendSelection && hasSelection()) { + setCursor(selectionStart(), false); + return; + } + setCursor(byWord ? previousWordBoundary(cursor) : Math.max(0, cursor - 1), extendSelection); + } + + void moveRight(boolean byWord, boolean extendSelection) { + if (!extendSelection && hasSelection()) { + setCursor(selectionEnd(), false); + return; + } + setCursor(byWord ? nextWordBoundary(cursor) : Math.min(text.length(), cursor + 1), extendSelection); + } + + void moveHome(boolean extendSelection) { + setCursor(0, extendSelection); + } + + void moveEnd(boolean extendSelection) { + setCursor(text.length(), extendSelection); + } + + void selectWordAt(int index) { + if (text.isEmpty()) { + setCursor(0, false); + return; + } + int clamped = Math.max(0, Math.min(index, text.length() - 1)); + char current = text.charAt(clamped); + int start = clamped; + int end = clamped + 1; + if (Character.isWhitespace(current)) { + while (start > 0 && Character.isWhitespace(text.charAt(start - 1))) { + start--; + } + while (end < text.length() && Character.isWhitespace(text.charAt(end))) { + end++; + } + } else if (isWordCharacter(current)) { + while (start > 0 && isWordCharacter(text.charAt(start - 1))) { + start--; + } + while (end < text.length() && isWordCharacter(text.charAt(end))) { + end++; + } + } + selectRange(start, end); + } + + int indexForX(Font textRenderer, double x) { + if (x <= 0) { + return 0; + } + int previousWidth = 0; + for (int index = 1; index <= text.length(); index++) { + int width = textRenderer.width(text.substring(0, index)); + if (x < width) { + return x - previousWidth < width - x ? index - 1 : index; + } + previousWidth = width; + } + return text.length(); + } + + void ensureCursorVisible(Font textRenderer, int innerWidth) { + int clampedInnerWidth = Math.max(1, innerWidth); + int cursorX = textRenderer.width(text.substring(0, cursor)); + int maxScroll = Math.max(0, textRenderer.width(text) - clampedInnerWidth); + if (cursorX < scrollX) { + scrollX = cursorX; + } else if (cursorX > scrollX + clampedInnerWidth - 1) { + scrollX = cursorX - (clampedInnerWidth - 1); + } + scrollX = Math.max(0, Math.min(scrollX, maxScroll)); + } + + private void replaceSelection(String replacement) { + replaceRange(selectionStart(), selectionEnd(), replacement); + } + + private void replaceRange(int start, int end, String replacement) { + Snapshot before = snapshot(); + int safeStart = clampIndex(start); + int safeEnd = clampIndex(end); + String sanitized = sanitize(replacement); + int available = MAX_LENGTH - (text.length() - (safeEnd - safeStart)); + if (available < sanitized.length()) { + sanitized = sanitized.substring(0, Math.max(0, available)); + } + String updated = text.substring(0, safeStart) + sanitized + text.substring(safeEnd); + if (text.equals(updated)) { + cursor = safeStart + sanitized.length(); + anchor = cursor; + return; + } + pushUndo(before); + redoStack.clear(); + text = updated; + cursor = safeStart + sanitized.length(); + anchor = cursor; + } + + private int previousWordBoundary(int index) { + int cursorIndex = clampIndex(index); + while (cursorIndex > 0 && Character.isWhitespace(text.charAt(cursorIndex - 1))) { + cursorIndex--; + } + if (cursorIndex > 0 && isWordCharacter(text.charAt(cursorIndex - 1))) { + while (cursorIndex > 0 && isWordCharacter(text.charAt(cursorIndex - 1))) { + cursorIndex--; + } + return cursorIndex; + } + while (cursorIndex > 0 && !Character.isWhitespace(text.charAt(cursorIndex - 1)) && !isWordCharacter(text.charAt(cursorIndex - 1))) { + cursorIndex--; + } + return cursorIndex; + } + + private int nextWordBoundary(int index) { + int cursorIndex = clampIndex(index); + while (cursorIndex < text.length() && Character.isWhitespace(text.charAt(cursorIndex))) { + cursorIndex++; + } + if (cursorIndex < text.length() && isWordCharacter(text.charAt(cursorIndex))) { + while (cursorIndex < text.length() && isWordCharacter(text.charAt(cursorIndex))) { + cursorIndex++; + } + return cursorIndex; + } + while (cursorIndex < text.length() && !Character.isWhitespace(text.charAt(cursorIndex)) && !isWordCharacter(text.charAt(cursorIndex))) { + cursorIndex++; + } + return cursorIndex; + } + + private int clampIndex(int index) { + return Math.max(0, Math.min(index, text.length())); + } + + private static boolean isWordCharacter(char character) { + return Character.isLetterOrDigit(character) || character == '_'; + } + + private static String sanitize(String value) { + return value.replace("\r", "").replace("\n", ""); + } + + private Snapshot snapshot() { + return new Snapshot(text, cursor, anchor, scrollX); + } + + private void restore(Snapshot snapshot) { + text = snapshot.text(); + cursor = snapshot.cursor(); + anchor = snapshot.anchor(); + scrollX = snapshot.scrollX(); + } + + private void pushUndo(Snapshot snapshot) { + undoStack.addLast(snapshot); + while (undoStack.size() > 128) { + undoStack.removeFirst(); + } + } + + private record Snapshot(String text, int cursor, int anchor, int scrollX) { + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphViewportState.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphViewportState.java new file mode 100644 index 0000000..884ef1d --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphViewportState.java @@ -0,0 +1,57 @@ +package com.github.squi2rel.mcng.fabric.client; + +public final class GraphViewportState { + public static final double MIN_ZOOM = 0.5; + public static final double MAX_ZOOM = 2.5; + + private double offsetX; + private double offsetY; + private double zoom = 1.0; + + public double offsetX() { + return offsetX; + } + + public double offsetY() { + return offsetY; + } + + public double zoom() { + return zoom; + } + + public void reset() { + offsetX = 40; + offsetY = 40; + zoom = 1.0; + } + + public void pan(double deltaX, double deltaY) { + offsetX += deltaX; + offsetY += deltaY; + } + + public void zoomAt(double amount, double screenX, double screenY) { + double oldZoom = zoom; + zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom + amount)); + double scaleRatio = zoom / oldZoom; + offsetX = screenX - ((screenX - offsetX) * scaleRatio); + offsetY = screenY - ((screenY - offsetY) * scaleRatio); + } + + public double toScreenX(double worldX) { + return (worldX * zoom) + offsetX; + } + + public double toScreenY(double worldY) { + return (worldY * zoom) + offsetY; + } + + public double toWorldX(double screenX) { + return (screenX - offsetX) / zoom; + } + + public double toWorldY(double screenY) { + return (screenY - offsetY) / zoom; + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/ImagePreviewNodeBodyComponent.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/ImagePreviewNodeBodyComponent.java new file mode 100644 index 0000000..576258f --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/ImagePreviewNodeBodyComponent.java @@ -0,0 +1,343 @@ +package com.github.squi2rel.mcng.fabric.client; + +import com.google.gson.JsonObject; +import com.mojang.blaze3d.platform.NativeImage; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.Files; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicInteger; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.client.renderer.texture.DynamicTexture; +import net.minecraft.resources.Identifier; + +final class ImagePreviewNodeBodyComponent implements NodeBodyComponent { + private static final AtomicInteger NEXT_TEXTURE_ID = new AtomicInteger(); + private static final List SUPPORTED_EXTENSIONS = List.of("png", "jpg", "jpeg", "bmp", "tga"); + private static final String FILE_PATH_KEY = "filePath"; + private static final int PADDING = 4; + private static final int FIELD_HEIGHT = 18; + private static final int BUTTON_HEIGHT = 18; + private static final int BUTTON_GAP = 6; + private static final int MIN_BODY_WIDTH = 180; + private static final int MIN_BODY_HEIGHT = 82; + private static final int PREFERRED_BODY_WIDTH = 224; + private static final int PREFERRED_BODY_HEIGHT = 142; + + private String loadedPath = null; + private LoadError loadError; + private Identifier textureId; + private DynamicTexture texture; + private int imageWidth; + private int imageHeight; + + @Override + public NodeBodyMeasurement measure(NodeBodyMeasureContext context) { + return new NodeBodyMeasurement(true, MIN_BODY_WIDTH, MIN_BODY_HEIGHT, PREFERRED_BODY_WIDTH, PREFERRED_BODY_HEIGHT); + } + + @Override + public void render(NodeBodyRenderContext context) { + Layout layout = Layout.forBounds(context.bounds()); + String filePath = filePath(context.configCopy()); + if (!context.preview()) { + syncTexture(filePath); + } + + renderPreviewArea(context, layout.previewBounds()); + renderPathField(context, layout.pathBounds(), filePath); + renderButtons(context, layout, filePath); + } + + @Override + public NodeInteractionResult mouseClicked(NodeBodyInputContext context, double localMouseX, double localMouseY, int button) { + if (button != 0) { + return NodeInteractionResult.ignored(); + } + + Layout layout = Layout.forSize(context.bounds().width(), context.bounds().height()); + String filePath = filePath(context.configCopy()); + if (layout.browseButton().contains(localMouseX, localMouseY)) { + if (!context.supportsFileDialogs()) { + context.showMessage(context.translate("mcng.ui.image_preview.file_dialogs_unsupported", "Host does not support file dialogs")); + return NodeInteractionResult.handledResult(); + } + context.chooseFile(new GraphFileDialogRequest(context.translate("mcng.ui.image_preview.choose_dialog_title", "Choose Image"), SUPPORTED_EXTENSIONS, filePath)) + .ifPresent(selectedPath -> updateFilePath(context, selectedPath)); + return NodeInteractionResult.handledResult(); + } + if (layout.clearButton().contains(localMouseX, localMouseY)) { + if (!filePath.isBlank()) { + updateFilePath(context, ""); + } + return NodeInteractionResult.handledResult(); + } + return NodeInteractionResult.ignored(); + } + + @Override + public void close() { + releaseTexture(); + } + + private void renderPreviewArea(NodeBodyRenderContext context, NodeWidget.Bounds bounds) { + GuiGraphicsExtractor drawContext = context.drawContext(); + GraphEditorTheme theme = context.theme(); + int fill = EditorStyleRenderer.darken(theme.nodeBodyColor(), 0.06f); + int border = loadError == null ? theme.panelBorderColor() : theme.errorColor(); + GraphEditorUiConfig uiConfig = context.uiConfig(); + EditorStyleRenderer.drawBox(drawContext, bounds.x(), bounds.y(), bounds.width(), bounds.height(), fill, border, uiConfig); + + if (context.preview()) { + drawCenteredLabel(drawContext, context.textRenderer(), bounds, context.translate("mcng.ui.image_preview.preview", "Image preview"), theme.secondaryTextColor()); + return; + } + if (textureId != null && imageWidth > 0 && imageHeight > 0) { + drawTexture(drawContext, bounds); + String info = imageWidth + " x " + imageHeight; + int infoWidth = context.textRenderer().width(info); + int infoX = bounds.x() + Math.max(PADDING, bounds.width() - infoWidth - PADDING); + int infoY = bounds.y() + Math.max(PADDING, bounds.height() - context.textRenderer().lineHeight - PADDING); + drawContext.fill(infoX - 3, infoY - 1, infoX + infoWidth + 3, infoY + context.textRenderer().lineHeight + 1, 0x99000000); + drawContext.text(context.textRenderer(), info, infoX, infoY, theme.primaryTextColor(), false); + return; + } + + String label = loadError != null + ? context.translate(loadError.translationKey(), loadError.fallback()) + : context.translate("mcng.ui.image_preview.no_image", "No image selected"); + int color = loadError != null ? theme.errorColor() : theme.secondaryTextColor(); + drawCenteredLabel(drawContext, context.textRenderer(), bounds, label, color); + } + + private void renderPathField(NodeBodyRenderContext context, NodeWidget.Bounds bounds, String filePath) { + GraphTextInputRenderer.renderFrame(context.drawContext(), bounds, context.theme(), context.uiConfig(), false); + String display = filePath.isBlank() + ? context.translate("mcng.ui.image_preview.no_file", "No file selected") + : trimLeading(context.textRenderer(), filePath, Math.max(1, bounds.width() - (GraphTextInputRenderer.CONTENT_PADDING_X * 2))); + int color = filePath.isBlank() ? context.theme().secondaryTextColor() : context.theme().primaryTextColor(); + int baselineY = bounds.y() + Math.max(2, (bounds.height() - context.textRenderer().lineHeight) / 2); + context.drawContext().text(context.textRenderer(), display, bounds.x() + GraphTextInputRenderer.CONTENT_PADDING_X, baselineY, color, false); + } + + private void renderButtons(NodeBodyRenderContext context, Layout layout, String filePath) { + boolean fileDialogsSupported = context.session().map(GraphEditorSession::supportsFileDialogs).orElse(false); + renderButton(context, layout.browseButtonBounds(), context.translate("mcng.ui.image_preview.browse", "Browse"), context.theme().accentColor(), fileDialogsSupported); + renderButton(context, layout.clearButtonBounds(), context.translate("mcng.ui.image_preview.clear", "Clear"), context.theme().panelBorderColor(), !filePath.isBlank()); + } + + private void renderButton(NodeBodyRenderContext context, NodeWidget.Bounds bounds, String label, int accentColor, boolean enabled) { + GraphEditorTheme theme = context.theme(); + int fill = enabled + ? EditorStyleRenderer.blend(theme.nodeBodyColor(), accentColor, 0.16f) + : EditorStyleRenderer.darken(theme.nodeBodyColor(), 0.08f); + int border = enabled ? accentColor : theme.panelBorderColor(); + int textColor = enabled ? theme.primaryTextColor() : theme.secondaryTextColor(); + EditorStyleRenderer.drawBox(context.drawContext(), bounds.x(), bounds.y(), bounds.width(), bounds.height(), fill, border, context.uiConfig()); + int textX = bounds.x() + Math.max(4, (bounds.width() - context.textRenderer().width(label)) / 2); + int textY = bounds.y() + Math.max(2, (bounds.height() - context.textRenderer().lineHeight) / 2); + context.drawContext().text(context.textRenderer(), label, textX, textY, textColor, false); + } + + private void drawTexture(GuiGraphicsExtractor context, NodeWidget.Bounds bounds) { + int availableWidth = Math.max(1, bounds.width() - (PADDING * 2)); + int availableHeight = Math.max(1, bounds.height() - (PADDING * 2)); + double scale = Math.min(availableWidth / (double) imageWidth, availableHeight / (double) imageHeight); + int drawWidth = Math.max(1, (int) Math.round(imageWidth * scale)); + int drawHeight = Math.max(1, (int) Math.round(imageHeight * scale)); + int drawX = bounds.x() + ((bounds.width() - drawWidth) / 2); + int drawY = bounds.y() + ((bounds.height() - drawHeight) / 2); + context.blit(RenderPipelines.GUI_TEXTURED, textureId, drawX, drawY, 0.0f, 0.0f, drawWidth, drawHeight, imageWidth, imageHeight, imageWidth, imageHeight); + } + + private void drawCenteredLabel(GuiGraphicsExtractor context, Font textRenderer, NodeWidget.Bounds bounds, String label, int color) { + String text = trimCenter(textRenderer, label, Math.max(1, bounds.width() - (PADDING * 2))); + int x = bounds.x() + Math.max(PADDING, (bounds.width() - textRenderer.width(text)) / 2); + int y = bounds.y() + Math.max(PADDING, (bounds.height() - textRenderer.lineHeight) / 2); + context.text(textRenderer, text, x, y, color, false); + } + + private void syncTexture(String filePath) { + String normalizedPath = filePath == null ? "" : filePath; + if (Objects.equals(loadedPath, normalizedPath)) { + return; + } + + releaseTexture(); + loadedPath = normalizedPath; + loadError = null; + if (normalizedPath.isBlank()) { + return; + } + + try { + Path path = Path.of(normalizedPath); + if (!Files.isRegularFile(path)) { + loadError = LoadError.FILE_NOT_FOUND; + return; + } + + try (InputStream stream = Files.newInputStream(path)) { + NativeImage image = NativeImage.read(stream); + Identifier id = Identifier.fromNamespaceAndPath("mcng", "image_preview/" + NEXT_TEXTURE_ID.incrementAndGet()); + DynamicTexture loadedTexture = new DynamicTexture(id::toString, image); + Minecraft client = Minecraft.getInstance(); + if (client == null) { + loadedTexture.close(); + loadError = LoadError.CLIENT_UNAVAILABLE; + return; + } + client.getTextureManager().register(id, loadedTexture); + textureId = id; + texture = loadedTexture; + imageWidth = image.getWidth(); + imageHeight = image.getHeight(); + } + } catch (InvalidPathException | IOException exception) { + loadError = LoadError.FAILED_TO_LOAD; + } + } + + private void releaseTexture() { + if (textureId != null) { + Minecraft client = Minecraft.getInstance(); + if (client != null) { + client.getTextureManager().release(textureId); + } else if (texture != null) { + texture.close(); + } + } else if (texture != null) { + texture.close(); + } + textureId = null; + texture = null; + imageWidth = 0; + imageHeight = 0; + loadError = null; + } + + private void updateFilePath(NodeBodyInputContext context, String filePath) { + JsonObject config = context.configCopy(); + config.addProperty(FILE_PATH_KEY, filePath); + context.updateConfig(config); + } + + private static String filePath(JsonObject config) { + if (config == null || !config.has(FILE_PATH_KEY)) { + return ""; + } + return config.get(FILE_PATH_KEY).getAsString(); + } + + private static String trimLeading(Font textRenderer, String text, int maxWidth) { + if (textRenderer.width(text) <= maxWidth) { + return text; + } + String ellipsis = "..."; + int ellipsisWidth = textRenderer.width(ellipsis); + if (ellipsisWidth >= maxWidth) { + return ellipsis; + } + String value = text; + while (!value.isEmpty() && textRenderer.width(value) + ellipsisWidth > maxWidth) { + value = value.substring(1); + } + return ellipsis + value; + } + + private static String trimCenter(Font textRenderer, String text, int maxWidth) { + if (textRenderer.width(text) <= maxWidth) { + return text; + } + String ellipsis = "..."; + int ellipsisWidth = textRenderer.width(ellipsis); + if (ellipsisWidth >= maxWidth) { + return ellipsis; + } + String value = text; + while (!value.isEmpty() && textRenderer.width(value) + ellipsisWidth > maxWidth) { + value = value.substring(0, value.length() - 1); + } + return value + ellipsis; + } + + private record Layout( + NodeWidget.Bounds preview, + NodeWidget.Bounds path, + NodeWidget.Bounds browseButton, + NodeWidget.Bounds clearButton + ) { + private static final int BROWSE_WIDTH = 58; + private static final int CLEAR_WIDTH = 44; + + static Layout forBounds(NodeWidget.Bounds bounds) { + Layout local = forSize(bounds.width(), bounds.height()); + return new Layout( + translate(bounds, local.preview()), + translate(bounds, local.path()), + translate(bounds, local.browseButton()), + translate(bounds, local.clearButton()) + ); + } + + static Layout forSize(int width, int height) { + int innerWidth = Math.max(24, width - (PADDING * 2)); + int previewHeight = Math.max(30, height - (PADDING * 4) - FIELD_HEIGHT - BUTTON_HEIGHT); + NodeWidget.Bounds preview = new NodeWidget.Bounds(PADDING, PADDING, innerWidth, previewHeight); + NodeWidget.Bounds path = new NodeWidget.Bounds(PADDING, preview.y() + preview.height() + PADDING, innerWidth, FIELD_HEIGHT); + int buttonY = path.y() + path.height() + PADDING; + int buttonRight = PADDING + innerWidth; + NodeWidget.Bounds clear = new NodeWidget.Bounds(buttonRight - CLEAR_WIDTH, buttonY, CLEAR_WIDTH, BUTTON_HEIGHT); + NodeWidget.Bounds browse = new NodeWidget.Bounds(clear.x() - BUTTON_GAP - BROWSE_WIDTH, buttonY, BROWSE_WIDTH, BUTTON_HEIGHT); + return new Layout(preview, path, browse, clear); + } + + NodeWidget.Bounds previewBounds() { + return preview; + } + + NodeWidget.Bounds pathBounds() { + return path; + } + + NodeWidget.Bounds browseButtonBounds() { + return browseButton; + } + + NodeWidget.Bounds clearButtonBounds() { + return clearButton; + } + + private static NodeWidget.Bounds translate(NodeWidget.Bounds base, NodeWidget.Bounds child) { + return new NodeWidget.Bounds(base.x() + child.x(), base.y() + child.y(), child.width(), child.height()); + } + } + + private enum LoadError { + FILE_NOT_FOUND("mcng.ui.image_preview.error.file_not_found", "File not found"), + CLIENT_UNAVAILABLE("mcng.ui.image_preview.error.client_unavailable", "Client unavailable"), + FAILED_TO_LOAD("mcng.ui.image_preview.error.failed_to_load", "Failed to load image"); + + private final String translationKey; + private final String fallback; + + LoadError(String translationKey, String fallback) { + this.translationKey = translationKey; + this.fallback = fallback; + } + + private String translationKey() { + return translationKey; + } + + private String fallback() { + return fallback; + } + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/MCNGDebugDocuments.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/MCNGDebugDocuments.java new file mode 100644 index 0000000..d2c5771 --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/MCNGDebugDocuments.java @@ -0,0 +1,29 @@ +package com.github.squi2rel.mcng.fabric.client; + +import com.github.squi2rel.mcng.core.GraphBuilder; +import com.github.squi2rel.mcng.core.GraphDocument; +import com.github.squi2rel.mcng.core.NodeConfigValues; +import com.github.squi2rel.mcng.core.NodePosition; +import com.github.squi2rel.mcng.core.NodeTypeRegistry; +import com.github.squi2rel.mcng.core.PortTypeRegistry; +import com.github.squi2rel.mcng.core.builtin.BuiltinNodeTypes; +import com.github.squi2rel.mcng.core.builtin.BuiltinNodeTypes.ManualTriggerConfig; + +public final class MCNGDebugDocuments { + private MCNGDebugDocuments() { + } + + public static GraphDocument createDefaultDocument(NodeTypeRegistry registry, PortTypeRegistry portTypes) { + GraphBuilder builder = new GraphBuilder(registry, portTypes); + var add = builder.addNode(BuiltinNodeTypes.ADD, BuiltinNodeTypes.EmptyConfig.INSTANCE, new NodePosition(40, 70)); + var resultDebug = builder.addNode(BuiltinNodeTypes.DEBUG_OUTPUT, BuiltinNodeTypes.EmptyConfig.INSTANCE, new NodePosition(320, 70)); + var trigger = builder.addNode(BuiltinNodeTypes.MANUAL_TRIGGER, new ManualTriggerConfig("pulse", true, "boxed"), new NodePosition(40, 250)); + var triggerDebug = builder.addNode(BuiltinNodeTypes.DEBUG_OUTPUT, BuiltinNodeTypes.EmptyConfig.INSTANCE, new NodePosition(320, 300)); + + builder.setInlineInput(add, "left", NodeConfigValues.numberValue(2.0)); + builder.setInlineInput(add, "right", NodeConfigValues.numberValue(3.0)); + builder.addEdge(add, "value", resultDebug, "value"); + builder.addEdge(trigger, "message", triggerDebug, "value"); + return builder.buildDocument(); + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/MCNGDebugScreen.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/MCNGDebugScreen.java new file mode 100644 index 0000000..8a00051 --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/MCNGDebugScreen.java @@ -0,0 +1,653 @@ +package com.github.squi2rel.mcng.fabric.client; + +import com.github.squi2rel.mcng.core.DocumentNodeDefinitionKind; +import com.github.squi2rel.mcng.core.GraphDocument; +import com.github.squi2rel.mcng.core.GraphError; +import com.github.squi2rel.mcng.core.GraphJsonCodec; +import com.github.squi2rel.mcng.core.GraphVariableDefinition; +import com.github.squi2rel.mcng.core.NodeTypeRegistry; +import com.github.squi2rel.mcng.core.PortTypeRegistry; +import org.lwjgl.PointerBuffer; +import org.lwjgl.glfw.GLFW; +import org.lwjgl.system.MemoryStack; +import org.lwjgl.util.tinyfd.TinyFileDialogs; + +import java.nio.ByteBuffer; +import java.util.List; +import java.util.Optional; +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.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.network.chat.Component; + +public final class MCNGDebugScreen extends Screen implements GraphEditorHost { + private static final int EXECUTION_STEP_BUDGET = 8; + private static final int DEBUG_PANEL_WIDTH = 280; + private static final int DEBUG_PANEL_PADDING = 8; + private static final int DEBUG_BUTTON_WIDTH = 128; + private static final int DEBUG_BUTTON_HEIGHT = 18; + private static final int DEBUG_BUTTON_GAP = 8; + private static final int DEBUG_BUTTON_ROW_GAP = 6; + private static final int DEBUG_SETTINGS_TITLE_OFFSET = 78; + private static final int DEBUG_SECTION_TITLE_GAP = 14; + private static final int DEBUG_SECTION_GAP = 10; + private static final int DEBUG_VARIABLE_ROW_HEIGHT = 16; + private static final int DEBUG_VARIABLE_ROW_STEP = 18; + private static final int DEBUG_VARIABLE_ROWS_VISIBLE = 5; + private static final GraphEditorI18n MINECRAFT_I18N = (key, fallback, args) -> { + String translated = I18n.get(key, args); + return translated.equals(key) ? GraphEditorI18n.formatFallback(fallback, key, args) : translated; + }; + private static final List THEME_OPTIONS = List.of( + new ThemeOption("classic", "Classic", GraphEditorTheme.classic()), + new ThemeOption("light", "Light", GraphEditorTheme.light()), + new ThemeOption("high_contrast", "High Contrast", GraphEditorTheme.highContrast()) + ); + + private final NodeTypeRegistry registry; + private final PortTypeRegistry portTypes; + private final NodePaletteRegistry paletteRegistry; + private final NodeComponentRegistry componentRegistry; + private final GraphJsonCodec codec; + private final Consumer onPersist; + private final Consumer statusSink; + private final GraphEditorSession session; + private final GraphEditorComponent editor; + + private int themePresetIndex; + private String statusMessage = ""; + private boolean debugPanelVisible = true; + private String selectedVariableId; + + public MCNGDebugScreen( + NodeTypeRegistry registry, + PortTypeRegistry portTypes, + NodePaletteRegistry paletteRegistry, + GraphJsonCodec codec, + GraphEditorUiConfig uiConfig, + GraphDocument initialDocument, + Consumer onPersist, + Consumer statusSink + ) { + this(registry, portTypes, paletteRegistry, new NodeComponentRegistry(), codec, uiConfig, initialDocument, onPersist, statusSink); + } + + public MCNGDebugScreen( + NodeTypeRegistry registry, + PortTypeRegistry portTypes, + NodePaletteRegistry paletteRegistry, + NodeComponentRegistry componentRegistry, + GraphJsonCodec codec, + GraphEditorUiConfig uiConfig, + GraphDocument initialDocument, + Consumer onPersist, + Consumer statusSink + ) { + super(minecraftClient(), minecraftTextRenderer(), Component.translatable("mcng.ui.debug.screen_title")); + this.registry = registry; + this.portTypes = portTypes; + this.paletteRegistry = paletteRegistry; + this.componentRegistry = componentRegistry; + this.codec = codec; + this.themePresetIndex = presetIndexFor(uiConfig.theme()); + this.onPersist = onPersist; + this.statusSink = statusSink; + this.session = new GraphEditorSession(registry, portTypes, codec, initialDocument, this); + this.editor = new GraphEditorComponent(session, paletteRegistry, componentRegistry, uiConfig); + this.statusMessage = translate("mcng.ui.debug.command_hint", "/mcng editor"); + } + + private static Minecraft minecraftClient() { + return Minecraft.getInstance(); + } + + private static Font minecraftTextRenderer() { + Minecraft client = Minecraft.getInstance(); + return client == null ? null : client.font; + } + + @Override + protected void init() { + super.init(); + editor.init(font, new GraphEditorBounds(0, 0, width, height)); + } + + @Override + public void tick() { + super.tick(); + session.tickExecution(EXECUTION_STEP_BUDGET); + } + + @Override + public void extractRenderState(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) { + editor.setBounds(new GraphEditorBounds(0, 0, width, height)); + editor.render(context, font, mouseX, mouseY, delta); + renderOverlay(context); + } + + @Override + public boolean mouseClicked(MouseButtonEvent click, boolean doubleClick) { + return mouseClicked(click.x(), click.y(), click.button()) || super.mouseClicked(click, doubleClick); + } + + public boolean mouseClicked(double mouseX, double mouseY, int button) { + if (button == 0 && clickVariableRow(mouseX, mouseY)) { + return true; + } + if (button == 0 && clickDebugButton(mouseX, mouseY)) { + return true; + } + if (overlayBlocksEditorAt(mouseX, mouseY)) { + return true; + } + return editor.mouseClicked(mouseX, mouseY, button); + } + + @Override + public boolean mouseDragged(MouseButtonEvent click, double deltaX, double deltaY) { + return mouseDragged(click.x(), click.y(), click.button(), deltaX, deltaY) || super.mouseDragged(click, deltaX, deltaY); + } + + public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) { + return editor.mouseDragged(mouseX, mouseY, button, deltaX, deltaY); + } + + @Override + public boolean mouseReleased(MouseButtonEvent click) { + return mouseReleased(click.x(), click.y(), click.button()) || super.mouseReleased(click); + } + + public boolean mouseReleased(double mouseX, double mouseY, int button) { + return editor.mouseReleased(mouseX, mouseY, button); + } + + @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) { + return keyPressed(input.key(), input.scancode(), input.modifiers()) || super.keyPressed(input); + } + + public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + if (editor.keyPressed(keyCode, scanCode, modifiers)) { + return true; + } + if (keyCode == GLFW.GLFW_KEY_O) { + debugPanelVisible = !debugPanelVisible; + showMessage(debugPanelVisible + ? translate("mcng.ui.debug.message.panel_shown", "Debug panel shown") + : translate("mcng.ui.debug.message.panel_hidden", "Debug panel hidden")); + return true; + } + + switch (keyCode) { + case GLFW.GLFW_KEY_E -> session.executeGraph(); + case GLFW.GLFW_KEY_T -> session.triggerSelectedEvent(); + case GLFW.GLFW_KEY_P -> session.cancelExecution(); + case GLFW.GLFW_KEY_J -> session.exportJson(); + case GLFW.GLFW_KEY_I -> session.importFromClipboard(); + case GLFW.GLFW_KEY_DELETE, GLFW.GLFW_KEY_BACKSPACE -> session.removeSelectedNode(); + case GLFW.GLFW_KEY_R -> { + session.replaceDocument(MCNGDebugDocuments.createDefaultDocument(registry, portTypes)); + showMessage(translate("mcng.ui.debug.message.reset_graph", "Reset debug graph")); + } + default -> { + return false; + } + } + return true; + } + + @Override + public boolean charTyped(CharacterEvent input) { + if (input.isAllowedChatCharacter()) { + String text = input.codepointAsString(); + if (text.length() == 1 && charTyped(text.charAt(0), 0)) { + return true; + } + } + return super.charTyped(input); + } + + public boolean charTyped(char chr, int modifiers) { + if (editor.charTyped(chr, modifiers)) { + return true; + } + return false; + } + + @Override + public void onClose() { + onPersist.accept(session.document()); + editor.close(); + super.onClose(); + } + + @Override + public void onDocumentChanged(GraphDocument document) { + onPersist.accept(document); + } + + @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) { + statusMessage = message; + statusSink.accept(message); + } + + @Override + public GraphEditorI18n i18n() { + return MINECRAFT_I18N; + } + + @Override + public boolean supportsFileDialogs() { + return true; + } + + @Override + public Optional chooseFile(GraphFileDialogRequest request) { + if (request == null) { + return Optional.empty(); + } + try (MemoryStack stack = MemoryStack.stackPush()) { + ByteBuffer[] patterns = request.extensions().stream() + .map(extension -> stack.UTF8("*." + extension)) + .toArray(ByteBuffer[]::new); + PointerBuffer filterPatterns = stack.pointers(patterns); + String initialPath = request.initialPath().isBlank() ? null : request.initialPath(); + String selected = TinyFileDialogs.tinyfd_openFileDialog(request.title(), initialPath, filterPatterns, translate("mcng.ui.debug.image_files_filter", "Image Files"), false); + return selected == null || selected.isBlank() ? Optional.empty() : Optional.of(selected); + } catch (RuntimeException | UnsatisfiedLinkError exception) { + showMessage(translate("mcng.ui.debug.message.file_dialog_failed", "Failed to open file dialog: %s", exception.getMessage())); + return Optional.empty(); + } + } + + private void renderOverlay(GuiGraphicsExtractor context) { + GraphEditorUiConfig uiConfig = editor.uiConfig(); + GraphEditorTheme theme = uiConfig.theme(); + List help = helpLines(); + + int panelWidth = 420; + int panelHeight = 110; + int x = editor.isPaletteOpen() ? editor.paletteSidebarRight() + 10 : 10; + int y = 36; + EditorStyleRenderer.drawBox(context, x, y, panelWidth, panelHeight, theme.panelBackgroundColor(), theme.panelBorderColor(), uiConfig); + context.text(font, title, x + 8, y + 8, theme.primaryTextColor(), false); + for (int index = 0; index < help.size(); index++) { + context.text(font, help.get(index), x + 8, y + 24 + (index * 12), theme.secondaryTextColor(), false); + } + + int statusY = y + panelHeight + 6; + EditorStyleRenderer.drawBox(context, x, statusY, panelWidth, 16, theme.panelBackgroundColor(), theme.panelBorderColor(), uiConfig); + context.text(font, translate("mcng.ui.debug.status", "Status: %s", statusMessage), x + 8, statusY + 4, theme.accentColor(), false); + + if (!debugPanelVisible) { + return; + } + + DebugPanelLayout layout = debugPanelLayout(); + EditorStyleRenderer.drawBox(context, layout.x(), layout.y(), layout.width(), layout.height(), theme.panelBackgroundColor(), theme.panelBorderColor(), uiConfig); + context.text(font, translate("mcng.ui.debug.panel_title", "Debug"), layout.x() + 8, layout.y() + 8, theme.primaryTextColor(), false); + context.text(font, session.isExecutionRunning() ? translate("mcng.ui.debug.running", "Running") : translate("mcng.ui.debug.idle", "Idle"), layout.x() + layout.width() - 46, layout.y() + 8, session.isExecutionRunning() ? theme.executionColor() : theme.secondaryTextColor(), false); + + List debug = session.debugMessages(); + for (int index = 0; index < Math.min(debug.size(), 2); index++) { + context.text(font, debug.get(debug.size() - 1 - index), layout.x() + 8, layout.y() + 24 + (index * 12), theme.secondaryTextColor(), false); + } + + List errors = session.lastErrors(); + for (int index = 0; index < Math.min(errors.size(), 2); index++) { + context.text(font, GraphEditorTranslations.formatError(i18n(), errors.get(index)), layout.x() + 8, layout.y() + 50 + (index * 10), theme.errorColor(), false); + } + + context.text(font, translate("mcng.ui.debug.section.editor", "Editor"), layout.x() + DEBUG_PANEL_PADDING, settingsTitleY(layout), theme.primaryTextColor(), false); + + for (DebugButton button : debugButtons(layout)) { + int fill = button.active() + ? EditorStyleRenderer.blend(theme.nodeBodyColor(), theme.accentColor(), 0.22f) + : theme.nodeBodyColor(); + int border = button.active() ? theme.accentColor() : theme.panelBorderColor(); + EditorStyleRenderer.drawBox(context, button.x(), button.y(), button.width(), button.height(), fill, border, uiConfig); + context.text(font, button.label(), button.x() + 6, button.y() + 5, theme.primaryTextColor(), false); + } + + context.text(font, translate("mcng.ui.debug.section.variables", "Variables"), layout.x() + DEBUG_PANEL_PADDING, variablesTitleY(layout), theme.primaryTextColor(), false); + for (VariableRow row : variableRows(layout)) { + int fill = row.selected() + ? EditorStyleRenderer.blend(theme.nodeBodyColor(), theme.accentColor(), 0.2f) + : EditorStyleRenderer.darken(theme.nodeBodyColor(), 0.02f); + EditorStyleRenderer.drawBox(context, row.x(), row.y(), row.width(), row.height(), fill, row.selected() ? theme.accentColor() : theme.panelBorderColor(), uiConfig); + context.text(font, row.label(), row.x() + 6, row.y() + 5, theme.secondaryTextColor(), false); + } + } + + private boolean clickDebugButton(double mouseX, double mouseY) { + if (!debugPanelVisible) { + return false; + } + for (DebugButton button : debugButtons(debugPanelLayout())) { + if (button.contains(mouseX, mouseY)) { + switch (button.action()) { + case CYCLE_THEME -> cycleTheme(); + case EDGE_STYLE -> cycleEdgeStyle(); + case NODE_CORNERS -> cycleNodeCorners(); + case PORT_SHAPE -> cyclePortShape(); + case NEW_SUBGRAPH -> createDefinitionAtVisibleCenter(DocumentNodeDefinitionKind.SUBGRAPH); + case NEW_CUSTOM -> createDefinitionAtVisibleCenter(DocumentNodeDefinitionKind.CUSTOM_NODE); + case SELECTION_SUBGRAPH -> session.createDefinitionFromSelection(DocumentNodeDefinitionKind.SUBGRAPH); + case SELECTION_CUSTOM -> session.createDefinitionFromSelection(DocumentNodeDefinitionKind.CUSTOM_NODE); + case NEW_VARIABLE -> { + GraphVariableDefinition variable = session.createVariable(); + selectedVariableId = variable.id(); + showMessage(translate("mcng.ui.debug.message.variable_created", "Created variable %s", variable.id())); + } + case CYCLE_VARIABLE_TYPE -> { + if (selectedVariableId != null && session.cycleVariableType(selectedVariableId)) { + showMessage(translate("mcng.ui.debug.message.variable_type_updated", "Variable type updated: %s", selectedVariableId)); + } + } + case DELETE_VARIABLE -> { + if (selectedVariableId != null && session.removeVariable(selectedVariableId)) { + showMessage(translate("mcng.ui.debug.message.variable_removed", "Removed variable %s", selectedVariableId)); + selectedVariableId = session.variables().stream().findFirst().map(GraphVariableDefinition::id).orElse(null); + } + } + case STOP_EXECUTION -> session.cancelExecution(); + } + return true; + } + } + return false; + } + + private boolean clickVariableRow(double mouseX, double mouseY) { + if (!debugPanelVisible) { + return false; + } + for (VariableRow row : variableRows(debugPanelLayout())) { + if (row.contains(mouseX, mouseY)) { + selectedVariableId = row.id(); + showMessage(translate("mcng.ui.debug.message.variable_selected", "Selected variable %s", row.id())); + return true; + } + } + return false; + } + + private void cycleTheme() { + themePresetIndex = (themePresetIndex + 1) % THEME_OPTIONS.size(); + ThemeOption option = THEME_OPTIONS.get(themePresetIndex); + editor.setUiConfig(editor.uiConfig().withTheme(option.theme())); + showMessage(translate("mcng.ui.debug.message.theme", "Theme: %s", themeLabel(option))); + } + + private void cycleEdgeStyle() { + EdgeStyle[] values = EdgeStyle.values(); + GraphEditorUiConfig uiConfig = editor.uiConfig(); + EdgeStyle next = values[(uiConfig.edgeStyle().ordinal() + 1) % values.length]; + editor.setUiConfig(uiConfig.withEdgeStyle(next)); + showMessage(translate("mcng.ui.debug.message.edge_style", "Edge style: %s", label(next))); + } + + private void cycleNodeCorners() { + GraphEditorUiConfig uiConfig = editor.uiConfig(); + NodeCornerStyle next = uiConfig.nodeCornerStyle() == NodeCornerStyle.ROUNDED ? NodeCornerStyle.SQUARE : NodeCornerStyle.ROUNDED; + editor.setUiConfig(uiConfig.withNodeCornerStyle(next)); + showMessage(translate("mcng.ui.debug.message.node_corners", "Node corners: %s", label(next))); + } + + private void cyclePortShape() { + GraphEditorUiConfig uiConfig = editor.uiConfig(); + PortShape next = uiConfig.portShape() == PortShape.CIRCLE ? PortShape.SQUARE : PortShape.CIRCLE; + editor.setUiConfig(uiConfig.withPortShape(next)); + showMessage(translate("mcng.ui.debug.message.port_shape", "Port shape: %s", label(next))); + } + + private void createDefinitionAtVisibleCenter(DocumentNodeDefinitionKind kind) { + GraphEditorBounds bounds = editor.bounds(); + double visibleLeft = editor.isPaletteOpen() ? editor.paletteSidebarRight() + 10.0 : bounds.x(); + double centerX = visibleLeft + ((bounds.right() - visibleLeft) / 2.0); + double centerY = bounds.y() + (bounds.height() / 2.0); + double x = editor.viewport().toWorldX(centerX - bounds.x()) - 80; + double y = editor.viewport().toWorldY(centerY - (bounds.y() + 28)) - 40; + session.createBlankDefinition(kind, x, y); + editor.viewport().reset(); + } + + private DebugPanelLayout debugPanelLayout() { + return new DebugPanelLayout(Math.max(10, width - (DEBUG_PANEL_WIDTH + 10)), 10, DEBUG_PANEL_WIDTH, debugPanelHeight()); + } + + private List debugButtons(DebugPanelLayout layout) { + int startX = layout.x() + DEBUG_PANEL_PADDING; + int rightX = startX + DEBUG_BUTTON_WIDTH + DEBUG_BUTTON_GAP; + int rowOneY = settingsRowsStartY(layout); + int rowTwoY = nextButtonRowY(rowOneY); + int rowThreeY = nextButtonRowY(rowTwoY); + int rowFourY = nextButtonRowY(rowThreeY); + int rowFiveY = variableActionsStartY(layout); + int rowSixY = nextButtonRowY(rowFiveY); + int rowSevenY = nextButtonRowY(rowSixY); + ThemeOption currentTheme = THEME_OPTIONS.get(themePresetIndex); + GraphEditorUiConfig uiConfig = editor.uiConfig(); + return List.of( + new DebugButton(startX, rowOneY, DEBUG_BUTTON_WIDTH, DEBUG_BUTTON_HEIGHT, translate("mcng.ui.debug.button.theme", "Theme: %s", themeLabel(currentTheme)), DebugAction.CYCLE_THEME, true), + new DebugButton(rightX, rowOneY, DEBUG_BUTTON_WIDTH, DEBUG_BUTTON_HEIGHT, translate("mcng.ui.debug.button.edge", "Edge: %s", label(uiConfig.edgeStyle())), DebugAction.EDGE_STYLE, true), + new DebugButton(startX, rowTwoY, DEBUG_BUTTON_WIDTH, DEBUG_BUTTON_HEIGHT, translate("mcng.ui.debug.button.node", "Node: %s", label(uiConfig.nodeCornerStyle())), DebugAction.NODE_CORNERS, uiConfig.nodeCornerStyle() == NodeCornerStyle.ROUNDED), + new DebugButton(rightX, rowTwoY, DEBUG_BUTTON_WIDTH, DEBUG_BUTTON_HEIGHT, translate("mcng.ui.debug.button.port", "Port: %s", label(uiConfig.portShape())), DebugAction.PORT_SHAPE, uiConfig.portShape() == PortShape.CIRCLE), + new DebugButton(startX, rowThreeY, DEBUG_BUTTON_WIDTH, DEBUG_BUTTON_HEIGHT, translate("mcng.ui.debug.button.new_subgraph", "New Subgraph"), DebugAction.NEW_SUBGRAPH, true), + new DebugButton(rightX, rowThreeY, DEBUG_BUTTON_WIDTH, DEBUG_BUTTON_HEIGHT, translate("mcng.ui.debug.button.new_custom", "New Custom"), DebugAction.NEW_CUSTOM, true), + new DebugButton(startX, rowFourY, DEBUG_BUTTON_WIDTH, DEBUG_BUTTON_HEIGHT, translate("mcng.ui.debug.button.selection_subgraph", "Sel -> Subgraph"), DebugAction.SELECTION_SUBGRAPH, !session.selectedNodeIds().isEmpty()), + new DebugButton(rightX, rowFourY, DEBUG_BUTTON_WIDTH, DEBUG_BUTTON_HEIGHT, translate("mcng.ui.debug.button.selection_custom", "Sel -> Custom"), DebugAction.SELECTION_CUSTOM, !session.selectedNodeIds().isEmpty()), + new DebugButton(startX, rowFiveY, DEBUG_BUTTON_WIDTH, DEBUG_BUTTON_HEIGHT, translate("mcng.ui.debug.button.new_variable", "New Var"), DebugAction.NEW_VARIABLE, true), + new DebugButton(rightX, rowFiveY, DEBUG_BUTTON_WIDTH, DEBUG_BUTTON_HEIGHT, translate("mcng.ui.debug.button.variable_type", "Var Type"), DebugAction.CYCLE_VARIABLE_TYPE, selectedVariableId != null), + new DebugButton(startX, rowSixY, (DEBUG_BUTTON_WIDTH * 2) + DEBUG_BUTTON_GAP, DEBUG_BUTTON_HEIGHT, translate("mcng.ui.debug.button.delete_variable", "Delete Var"), DebugAction.DELETE_VARIABLE, selectedVariableId != null), + new DebugButton(startX, rowSevenY, (DEBUG_BUTTON_WIDTH * 2) + DEBUG_BUTTON_GAP, DEBUG_BUTTON_HEIGHT, translate("mcng.ui.debug.button.stop", "Stop [%s]", GraphInputText.key(GLFW.GLFW_KEY_P)), DebugAction.STOP_EXECUTION, session.isExecutionRunning()) + ); + } + + private List variableRows(DebugPanelLayout layout) { + List variables = session.variables(); + List rows = new java.util.ArrayList<>(); + int x = layout.x() + DEBUG_PANEL_PADDING; + int y = variableRowsStartY(layout); + int width = layout.width() - (DEBUG_PANEL_PADDING * 2); + for (int index = 0; index < Math.min(variables.size(), DEBUG_VARIABLE_ROWS_VISIBLE); index++) { + GraphVariableDefinition variable = variables.get(index); + String label = variable.id() + " : " + shortTypeLabel(variable.typeId()); + rows.add(new VariableRow(variable.id(), x, y + (index * DEBUG_VARIABLE_ROW_STEP), width, DEBUG_VARIABLE_ROW_HEIGHT, label, variable.id().equals(selectedVariableId))); + } + return rows; + } + + private static int debugPanelHeight() { + int lastButtonTop = variableActionsStartOffset() + ((DEBUG_BUTTON_HEIGHT + DEBUG_BUTTON_ROW_GAP) * 2); + return lastButtonTop + DEBUG_BUTTON_HEIGHT + DEBUG_PANEL_PADDING; + } + + private static int settingsTitleY(DebugPanelLayout layout) { + return layout.y() + DEBUG_SETTINGS_TITLE_OFFSET; + } + + private static int settingsRowsStartY(DebugPanelLayout layout) { + return layout.y() + settingsRowsStartOffset(); + } + + private static int variablesTitleY(DebugPanelLayout layout) { + return layout.y() + variablesTitleOffset(); + } + + private static int variableRowsStartY(DebugPanelLayout layout) { + return layout.y() + variableRowsStartOffset(); + } + + private static int variableActionsStartY(DebugPanelLayout layout) { + return layout.y() + variableActionsStartOffset(); + } + + private static int nextButtonRowY(int currentRowY) { + return currentRowY + DEBUG_BUTTON_HEIGHT + DEBUG_BUTTON_ROW_GAP; + } + + private static int settingsRowsStartOffset() { + return DEBUG_SETTINGS_TITLE_OFFSET + DEBUG_SECTION_TITLE_GAP; + } + + private static int settingsButtonsBottomOffset() { + return settingsRowsStartOffset() + ((DEBUG_BUTTON_HEIGHT + DEBUG_BUTTON_ROW_GAP) * 3) + DEBUG_BUTTON_HEIGHT; + } + + private static int variablesTitleOffset() { + return settingsButtonsBottomOffset() + DEBUG_SECTION_GAP; + } + + private static int variableRowsStartOffset() { + return variablesTitleOffset() + DEBUG_SECTION_TITLE_GAP; + } + + private static int variableActionsStartOffset() { + return variableRowsStartOffset() + (DEBUG_VARIABLE_ROWS_VISIBLE * DEBUG_VARIABLE_ROW_STEP) + DEBUG_SECTION_GAP; + } + + private String translate(String key, String fallback, Object... args) { + return i18n().translate(key, fallback, args); + } + + private List helpLines() { + String leftMouse = GraphInputText.mouse(GLFW.GLFW_MOUSE_BUTTON_LEFT); + String rightMouse = GraphInputText.mouse(GLFW.GLFW_MOUSE_BUTTON_RIGHT); + String shift = GraphInputText.key(GLFW.GLFW_KEY_LEFT_SHIFT); + String control = GraphInputText.key(GLFW.GLFW_KEY_LEFT_CONTROL); + String tab = GraphInputText.key(GLFW.GLFW_KEY_TAB); + return List.of( + translate("mcng.ui.debug.help.palette_toggle", "%s: toggle node palette", tab), + translate("mcng.ui.debug.help.panel_toggle", "%s: toggle debug panel", GraphInputText.key(GLFW.GLFW_KEY_O)), + translate("mcng.ui.debug.help.enter_definition", "Double %s subgraph/custom node: enter definition", leftMouse), + translate("mcng.ui.debug.help.palette_drag", "%s a palette entry to add at center, or drag it onto the canvas", leftMouse), + translate("mcng.ui.debug.help.selection", "%s: select/drag or connect ports %s+%s / drag box: multi-select", leftMouse, shift, leftMouse), + translate("mcng.ui.debug.help.secondary", "%s while wiring: cancel %s port: clear edges %s drag/release: pan or menu", rightMouse, rightMouse, rightMouse), + translate("mcng.ui.debug.help.shortcuts", "%s undo %s redo %s execute %s trigger %s stop %s delete", + GraphInputText.shortcut(control, GLFW.GLFW_KEY_Z), + control + "+" + shift + "+" + GraphInputText.key(GLFW.GLFW_KEY_Z), + GraphInputText.key(GLFW.GLFW_KEY_E), + GraphInputText.key(GLFW.GLFW_KEY_T), + GraphInputText.key(GLFW.GLFW_KEY_P), + GraphInputText.key(GLFW.GLFW_KEY_DELETE)) + ); + } + + private int presetIndexFor(GraphEditorTheme theme) { + for (int index = 0; index < THEME_OPTIONS.size(); index++) { + if (THEME_OPTIONS.get(index).theme().equals(theme)) { + return index; + } + } + return 0; + } + + private String label(EdgeStyle edgeStyle) { + return switch (edgeStyle) { + case STRAIGHT -> translate("mcng.ui.debug.edge_style.straight", "Straight"); + case CURVE -> translate("mcng.ui.debug.edge_style.curve", "Curve"); + case ORTHOGONAL -> translate("mcng.ui.debug.edge_style.orthogonal", "Ortho"); + }; + } + + private String label(NodeCornerStyle nodeCornerStyle) { + return nodeCornerStyle == NodeCornerStyle.ROUNDED + ? translate("mcng.ui.debug.node_corner.rounded", "Rounded") + : translate("mcng.ui.debug.node_corner.square", "Square"); + } + + private String label(PortShape portShape) { + return portShape == PortShape.CIRCLE + ? translate("mcng.ui.debug.port_shape.circle", "Circle") + : translate("mcng.ui.debug.port_shape.square", "Square"); + } + + private boolean overlayBlocksEditorAt(double mouseX, double mouseY) { + int panelWidth = 420; + int panelHeight = 110; + int x = editor.isPaletteOpen() ? editor.paletteSidebarRight() + 10 : 10; + int y = 36; + if (contains(mouseX, mouseY, x, y, panelWidth, panelHeight)) { + return true; + } + int statusY = y + panelHeight + 6; + if (contains(mouseX, mouseY, x, statusY, panelWidth, 16)) { + return true; + } + if (debugPanelVisible) { + DebugPanelLayout layout = debugPanelLayout(); + return contains(mouseX, mouseY, layout.x(), layout.y(), layout.width(), layout.height()); + } + return false; + } + + private boolean contains(double mouseX, double mouseY, int x, int y, int width, int height) { + return mouseX >= x && mouseX <= x + width && mouseY >= y && mouseY <= y + height; + } + + private enum DebugAction { + CYCLE_THEME, + EDGE_STYLE, + NODE_CORNERS, + PORT_SHAPE, + NEW_SUBGRAPH, + NEW_CUSTOM, + SELECTION_SUBGRAPH, + SELECTION_CUSTOM, + NEW_VARIABLE, + CYCLE_VARIABLE_TYPE, + DELETE_VARIABLE, + STOP_EXECUTION + } + + private String shortTypeLabel(String typeId) { + return GraphEditorTranslations.shortPortTypeLabel(i18n(), typeId); + } + + private String themeLabel(ThemeOption option) { + return translate("mcng.ui.debug.theme." + option.id(), option.fallbackName()); + } + + private record ThemeOption(String id, String fallbackName, GraphEditorTheme theme) { + } + + private record DebugPanelLayout(int x, int y, int width, int height) { + } + + private record DebugButton(int x, int y, int width, int height, String label, DebugAction action, boolean active) { + boolean contains(double mouseX, double mouseY) { + return mouseX >= x && mouseX <= x + width && mouseY >= y && mouseY <= y + height; + } + } + + private record VariableRow(String id, int x, int y, int width, int height, String label, boolean selected) { + boolean contains(double mouseX, double mouseY) { + return mouseX >= x && mouseX <= x + width && mouseY >= y && mouseY <= y + height; + } + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeBodyComponent.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeBodyComponent.java new file mode 100644 index 0000000..325bbf8 --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeBodyComponent.java @@ -0,0 +1,40 @@ +package com.github.squi2rel.mcng.fabric.client; + +public interface NodeBodyComponent { + default NodeBodyMeasurement measure(NodeBodyMeasureContext context) { + return NodeBodyMeasurement.hidden(); + } + + default void render(NodeBodyRenderContext context) { + } + + default NodeInteractionResult mouseClicked(NodeBodyInputContext context, double localMouseX, double localMouseY, int button) { + return NodeInteractionResult.ignored(); + } + + default NodeInteractionResult mouseDragged(NodeBodyInputContext context, double localMouseX, double localMouseY, int button, double deltaX, double deltaY) { + return NodeInteractionResult.ignored(); + } + + default NodeInteractionResult mouseReleased(NodeBodyInputContext context, double localMouseX, double localMouseY, int button) { + return NodeInteractionResult.ignored(); + } + + default NodeInteractionResult mouseScrolled(NodeBodyInputContext context, double localMouseX, double localMouseY, double horizontalAmount, double verticalAmount) { + return NodeInteractionResult.ignored(); + } + + default boolean keyPressed(NodeBodyInputContext context, int keyCode, int scanCode, int modifiers) { + return false; + } + + default boolean charTyped(NodeBodyInputContext context, char chr, int modifiers) { + return false; + } + + default void blur() { + } + + default void close() { + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeBodyComponentFactory.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeBodyComponentFactory.java new file mode 100644 index 0000000..ad4a7c2 --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeBodyComponentFactory.java @@ -0,0 +1,6 @@ +package com.github.squi2rel.mcng.fabric.client; + +@FunctionalInterface +public interface NodeBodyComponentFactory { + NodeBodyComponent create(); +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeBodyInputContext.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeBodyInputContext.java new file mode 100644 index 0000000..a33d7bc --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeBodyInputContext.java @@ -0,0 +1,58 @@ +package com.github.squi2rel.mcng.fabric.client; + +import com.github.squi2rel.mcng.core.NodeId; +import com.github.squi2rel.mcng.core.NodeInstance; +import com.github.squi2rel.mcng.core.NodeType; +import com.google.gson.JsonObject; + +import java.util.Optional; +import java.util.Objects; + +public record NodeBodyInputContext( + NodeWidget.Bounds bounds, + NodeInstance node, + NodeType nodeType, + GraphEditorSession session, + GraphEditorI18n i18n, + GraphEditorUiConfig uiConfig, + GraphEditorTheme theme, + double zoom +) { + public NodeBodyInputContext { + Objects.requireNonNull(bounds, "bounds"); + Objects.requireNonNull(node, "node"); + Objects.requireNonNull(nodeType, "nodeType"); + Objects.requireNonNull(session, "session"); + Objects.requireNonNull(i18n, "i18n"); + Objects.requireNonNull(uiConfig, "uiConfig"); + Objects.requireNonNull(theme, "theme"); + } + + public NodeId nodeId() { + return node.id(); + } + + public JsonObject configCopy() { + return node.config().deepCopy(); + } + + public void updateConfig(JsonObject config) { + session.updateNodeConfig(node.id(), config); + } + + public boolean supportsFileDialogs() { + return session.supportsFileDialogs(); + } + + public Optional chooseFile(GraphFileDialogRequest request) { + return session.chooseFile(request); + } + + public void showMessage(String message) { + session.showMessage(message); + } + + public String translate(String key, String fallback, Object... args) { + return i18n.translate(key, fallback, args); + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeBodyMeasureContext.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeBodyMeasureContext.java new file mode 100644 index 0000000..dab004d --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeBodyMeasureContext.java @@ -0,0 +1,40 @@ +package com.github.squi2rel.mcng.fabric.client; + +import com.github.squi2rel.mcng.core.NodeInstance; +import com.github.squi2rel.mcng.core.NodeType; +import com.google.gson.JsonObject; + +import java.util.Objects; +import java.util.Optional; + +public record NodeBodyMeasureContext( + NodeInstance node, + NodeType nodeType, + Optional session, + GraphEditorI18n i18n, + GraphEditorUiConfig uiConfig, + GraphEditorTheme theme, + double zoom, + boolean preview, + int availableWidth +) { + public NodeBodyMeasureContext { + Objects.requireNonNull(node, "node"); + Objects.requireNonNull(nodeType, "nodeType"); + Objects.requireNonNull(session, "session"); + Objects.requireNonNull(i18n, "i18n"); + Objects.requireNonNull(uiConfig, "uiConfig"); + Objects.requireNonNull(theme, "theme"); + if (availableWidth <= 0) { + throw new IllegalArgumentException("availableWidth must be positive"); + } + } + + public JsonObject configCopy() { + return node.config().deepCopy(); + } + + public String translate(String key, String fallback, Object... args) { + return i18n.translate(key, fallback, args); + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeBodyMeasurement.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeBodyMeasurement.java new file mode 100644 index 0000000..2034971 --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeBodyMeasurement.java @@ -0,0 +1,34 @@ +package com.github.squi2rel.mcng.fabric.client; + +public record NodeBodyMeasurement( + boolean visible, + int minWidth, + int minHeight, + int preferredWidth, + int preferredHeight +) { + public NodeBodyMeasurement { + if (visible) { + if (minWidth <= 0) { + throw new IllegalArgumentException("minWidth must be positive for visible node bodies"); + } + if (minHeight <= 0) { + throw new IllegalArgumentException("minHeight must be positive for visible node bodies"); + } + if (preferredWidth < minWidth) { + throw new IllegalArgumentException("preferredWidth must be at least minWidth"); + } + if (preferredHeight < minHeight) { + throw new IllegalArgumentException("preferredHeight must be at least minHeight"); + } + } + } + + public static NodeBodyMeasurement hidden() { + return new NodeBodyMeasurement(false, 0, 0, 0, 0); + } + + public static NodeBodyMeasurement fixed(int width, int height) { + return new NodeBodyMeasurement(true, width, height, width, height); + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeBodyRenderContext.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeBodyRenderContext.java new file mode 100644 index 0000000..6d0885b --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeBodyRenderContext.java @@ -0,0 +1,46 @@ +package com.github.squi2rel.mcng.fabric.client; + +import com.github.squi2rel.mcng.core.NodeInstance; +import com.github.squi2rel.mcng.core.NodeType; +import com.google.gson.JsonObject; +import java.util.Objects; +import java.util.Optional; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphicsExtractor; + +public record NodeBodyRenderContext( + GuiGraphicsExtractor drawContext, + Font textRenderer, + NodeWidget.Bounds bounds, + NodeInstance node, + NodeType nodeType, + Optional session, + GraphEditorI18n i18n, + GraphEditorUiConfig uiConfig, + GraphEditorTheme theme, + double zoom, + boolean selected, + boolean executing, + boolean hasError, + boolean preview +) { + public NodeBodyRenderContext { + Objects.requireNonNull(drawContext, "drawContext"); + Objects.requireNonNull(textRenderer, "textRenderer"); + Objects.requireNonNull(bounds, "bounds"); + Objects.requireNonNull(node, "node"); + Objects.requireNonNull(nodeType, "nodeType"); + Objects.requireNonNull(session, "session"); + Objects.requireNonNull(i18n, "i18n"); + Objects.requireNonNull(uiConfig, "uiConfig"); + Objects.requireNonNull(theme, "theme"); + } + + public JsonObject configCopy() { + return node.config().deepCopy(); + } + + public String translate(String key, String fallback, Object... args) { + return i18n.translate(key, fallback, args); + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeComponentDefinition.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeComponentDefinition.java new file mode 100644 index 0000000..f829c5b --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeComponentDefinition.java @@ -0,0 +1,17 @@ +package com.github.squi2rel.mcng.fabric.client; + +import java.util.Objects; + +public record NodeComponentDefinition( + String nodeTypeId, + NodeBodyComponentFactory factory, + ResizePolicy resizePolicy +) { + public NodeComponentDefinition { + if (nodeTypeId == null || nodeTypeId.isBlank()) { + throw new IllegalArgumentException("nodeTypeId must not be blank"); + } + Objects.requireNonNull(factory, "factory"); + Objects.requireNonNull(resizePolicy, "resizePolicy"); + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeComponentRegistry.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeComponentRegistry.java new file mode 100644 index 0000000..b3f0e70 --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeComponentRegistry.java @@ -0,0 +1,28 @@ +package com.github.squi2rel.mcng.fabric.client; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +public final class NodeComponentRegistry { + private final Map definitions = new LinkedHashMap<>(); + + public synchronized NodeComponentRegistry register(NodeComponentDefinition definition) { + Objects.requireNonNull(definition, "definition"); + NodeComponentDefinition existing = definitions.putIfAbsent(definition.nodeTypeId(), definition); + if (existing != null) { + throw new IllegalArgumentException("Duplicate node component node type id: " + definition.nodeTypeId()); + } + return this; + } + + public synchronized Optional find(String nodeTypeId) { + return Optional.ofNullable(definitions.get(nodeTypeId)); + } + + public synchronized List all() { + return List.copyOf(definitions.values()); + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeCornerStyle.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeCornerStyle.java new file mode 100644 index 0000000..9ab5fe3 --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeCornerStyle.java @@ -0,0 +1,6 @@ +package com.github.squi2rel.mcng.fabric.client; + +public enum NodeCornerStyle { + ROUNDED, + SQUARE +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeInteractionResult.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeInteractionResult.java new file mode 100644 index 0000000..5a7eaff --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeInteractionResult.java @@ -0,0 +1,23 @@ +package com.github.squi2rel.mcng.fabric.client; + +public record NodeInteractionResult( + boolean handled, + boolean requestFocus, + boolean capturePointer +) { + public static NodeInteractionResult ignored() { + return new NodeInteractionResult(false, false, false); + } + + public static NodeInteractionResult handledResult() { + return new NodeInteractionResult(true, false, false); + } + + public static NodeInteractionResult focusHandled() { + return new NodeInteractionResult(true, true, false); + } + + public static NodeInteractionResult captured() { + return new NodeInteractionResult(true, true, true); + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodePaletteAction.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodePaletteAction.java new file mode 100644 index 0000000..53a7cbe --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodePaletteAction.java @@ -0,0 +1,9 @@ +package com.github.squi2rel.mcng.fabric.client; + +sealed interface NodePaletteAction permits NodePaletteAction.CreateAtCenter, NodePaletteAction.CreateAtPointer { + record CreateAtCenter(String nodeTypeId) implements NodePaletteAction { + } + + record CreateAtPointer(String nodeTypeId, double screenX, double screenY) implements NodePaletteAction { + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodePaletteCatalog.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodePaletteCatalog.java new file mode 100644 index 0000000..34058cb --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodePaletteCatalog.java @@ -0,0 +1,129 @@ +package com.github.squi2rel.mcng.fabric.client; + +import com.github.squi2rel.mcng.core.DocumentNodeDefinition; +import com.github.squi2rel.mcng.core.DocumentNodeDefinitionKind; +import com.github.squi2rel.mcng.core.DocumentNodeTypes; +import com.github.squi2rel.mcng.core.NodeType; +import com.github.squi2rel.mcng.core.NodeTypeRegistry; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +final class NodePaletteCatalog { + private static final int GRAPH_SECTION_ORDER = 10_000; + private static final int CUSTOM_SECTION_ORDER = 10_100; + + private NodePaletteCatalog() { + } + + static List buildSections(GraphEditorSession session, NodePaletteRegistry paletteRegistry) { + NodeTypeRegistry registry = session.resolvedRegistry(); + GraphEditorI18n i18n = session.i18n(); + Map sections = new LinkedHashMap<>(); + + for (NodePaletteDefinition definition : paletteRegistry.all()) { + NodeType nodeType = registry.find(definition.nodeTypeId()).orElse(null); + if (nodeType == null) { + continue; + } + String sectionTitle = GraphEditorTranslations.paletteSection(i18n, definition); + sections.computeIfAbsent( + sectionTitle, + title -> new SectionBuilder(title, definition.sectionOrder()) + ).entries().add(toEntry(i18n, nodeType, sectionTitle, definition.sectionTitle(), definition.searchKeywords(), definition.itemOrder())); + } + + if (session.isInsideDefinition()) { + String graphTitle = GraphEditorTranslations.ui(i18n, "palette.section.graph", "Graph"); + SectionBuilder graphSection = sections.computeIfAbsent(graphTitle, title -> new SectionBuilder(title, GRAPH_SECTION_ORDER)); + if (session.currentDefinitionKind().orElse(null) == DocumentNodeDefinitionKind.SUBGRAPH) { + graphSection.entries().add(toEntry(i18n, registry.getOrThrow(DocumentNodeTypes.SUBGRAPH_INPUT_TYPE_ID), graphTitle, "Graph", List.of("subgraph", "input"), -20)); + graphSection.entries().add(toEntry(i18n, registry.getOrThrow(DocumentNodeTypes.SUBGRAPH_OUTPUT_TYPE_ID), graphTitle, "Graph", List.of("subgraph", "output"), -10)); + graphSection.entries().add(toEntry(i18n, registry.getOrThrow(DocumentNodeTypes.SUBGRAPH_FLOW_INPUT_TYPE_ID), graphTitle, "Graph", List.of("flow", "control"), 0)); + graphSection.entries().add(toEntry(i18n, registry.getOrThrow(DocumentNodeTypes.SUBGRAPH_FLOW_OUTPUT_TYPE_ID), graphTitle, "Graph", List.of("flow", "control"), 10)); + } else { + graphSection.entries().add(toEntry(i18n, registry.getOrThrow(DocumentNodeTypes.GRAPH_INPUT_TYPE_ID), graphTitle, "Graph", List.of(), -20)); + graphSection.entries().add(toEntry(i18n, registry.getOrThrow(DocumentNodeTypes.GRAPH_OUTPUT_TYPE_ID), graphTitle, "Graph", List.of(), -10)); + } + } + + List customEntries = session.definitions().stream() + .filter(definition -> definition.kind() == DocumentNodeDefinitionKind.CUSTOM_NODE) + .filter(definition -> !definition.id().equals(session.currentDefinitionId())) + .sorted(Comparator.comparing(DocumentNodeDefinition::displayName)) + .map(definition -> { + String customTitle = GraphEditorTranslations.ui(i18n, "palette.section.custom", "Custom"); + return toEntry(i18n, registry.getOrThrow(DocumentNodeTypes.definitionTypeId(definition.id())), customTitle, "Custom", List.of("custom"), 0); + }) + .toList(); + if (!customEntries.isEmpty()) { + String customTitle = GraphEditorTranslations.ui(i18n, "palette.section.custom", "Custom"); + sections.computeIfAbsent(customTitle, title -> new SectionBuilder(title, CUSTOM_SECTION_ORDER)).entries().addAll(customEntries); + } + + return sections.values().stream() + .sorted(Comparator.comparingInt(SectionBuilder::order).thenComparing(SectionBuilder::title)) + .map(section -> new NodePaletteSection( + section.title(), + section.order(), + section.entries().stream() + .sorted(Comparator.comparingInt(NodePaletteEntry::itemOrder).thenComparing(NodePaletteEntry::displayName)) + .toList() + )) + .toList(); + } + + static List filterSections(List sections, String query) { + if (query == null || query.isBlank()) { + return sections; + } + + String normalized = query.toLowerCase(Locale.ROOT); + List filtered = new ArrayList<>(); + for (NodePaletteSection section : sections) { + boolean groupMatches = section.title().toLowerCase(Locale.ROOT).contains(normalized); + List entries = section.entries().stream() + .filter(entry -> groupMatches || matches(entry, normalized)) + .toList(); + if (!entries.isEmpty()) { + filtered.add(new NodePaletteSection(section.title(), section.order(), entries)); + } + } + return filtered; + } + + private static boolean matches(NodePaletteEntry entry, String normalizedQuery) { + return entry.displayName().toLowerCase(Locale.ROOT).contains(normalizedQuery) + || entry.nodeTypeId().toLowerCase(Locale.ROOT).contains(normalizedQuery) + || entry.subtitle().toLowerCase(Locale.ROOT).contains(normalizedQuery) + || entry.groupName().toLowerCase(Locale.ROOT).contains(normalizedQuery) + || entry.searchKeywords().stream().anyMatch(keyword -> keyword.toLowerCase(Locale.ROOT).contains(normalizedQuery)); + } + + private static NodePaletteEntry toEntry(GraphEditorI18n i18n, NodeType nodeType, String groupTitle, String fallbackGroupTitle, List searchKeywords, int itemOrder) { + String fallbackDisplayName = DocumentNodeTypes.readDefinitionDisplayName(nodeType); + List resolvedSearchKeywords = new ArrayList<>(searchKeywords.size() + 3); + resolvedSearchKeywords.addAll(searchKeywords); + resolvedSearchKeywords.add(fallbackDisplayName); + resolvedSearchKeywords.add(nodeType.id()); + resolvedSearchKeywords.add(fallbackGroupTitle); + return new NodePaletteEntry( + nodeType.id(), + GraphEditorTranslations.nodeTitle(i18n, nodeType), + nodeType.id(), + groupTitle, + itemOrder, + resolvedSearchKeywords + ); + } + + private record SectionBuilder(String title, int order, List entries) { + private SectionBuilder(String title, int order) { + this(title, order, new ArrayList<>()); + } + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodePaletteComponent.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodePaletteComponent.java new file mode 100644 index 0000000..81e0554 --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodePaletteComponent.java @@ -0,0 +1,449 @@ +package com.github.squi2rel.mcng.fabric.client; + +import com.github.squi2rel.mcng.core.NodeType; +import com.github.squi2rel.mcng.core.PortChannel; +import com.github.squi2rel.mcng.core.NodeTypeRegistry; +import com.github.squi2rel.mcng.core.PortTypeRegistry; +import org.lwjgl.glfw.GLFW; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Supplier; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphicsExtractor; + +public final class NodePaletteComponent { + private static final NodeComponentRegistry EMPTY_COMPONENT_REGISTRY = new NodeComponentRegistry(); + private static final int TOGGLE_MARGIN_X = 10; + private static final int TOGGLE_MARGIN_Y = 6; + private static final int TOGGLE_WIDTH = 90; + private static final int TOGGLE_HEIGHT = 20; + private static final int PANEL_MARGIN_X = 10; + private static final int PANEL_TOP = 32; + private static final int PANEL_WIDTH = 220; + private static final int PANEL_PADDING = 10; + private static final int SEARCH_HEIGHT = 18; + private static final int SECTION_HEADER_HEIGHT = 16; + private static final int ENTRY_HEIGHT = 24; + private static final int LIST_TOP_GAP = 8; + private static final int LIST_BOTTOM_PADDING = 10; + private static final int SCROLL_STEP = 18; + private static final double DRAG_THRESHOLD = 4.0; + + private final Supplier> sectionsSupplier; + private final Supplier registrySupplier; + private final Supplier componentRegistrySupplier; + private final NodePaletteState state = new NodePaletteState(); + private final Supplier uiConfigSupplier; + private final Supplier i18nSupplier; + private final PortTypeRegistry portTypes; + private final GraphTextFieldComponent searchField; + + private GraphEditorBounds bounds = new GraphEditorBounds(0, 0, 0, 0); + private Font textRenderer; + + public NodePaletteComponent( + Supplier> sectionsSupplier, + Supplier registrySupplier, + PortTypeRegistry portTypes, + Supplier uiConfigSupplier, + Supplier i18nSupplier, + Supplier clipboardReader, + Consumer clipboardWriter + ) { + this(sectionsSupplier, registrySupplier, () -> EMPTY_COMPONENT_REGISTRY, portTypes, uiConfigSupplier, i18nSupplier, clipboardReader, clipboardWriter); + } + + public NodePaletteComponent( + Supplier> sectionsSupplier, + Supplier registrySupplier, + Supplier componentRegistrySupplier, + PortTypeRegistry portTypes, + Supplier uiConfigSupplier, + Supplier i18nSupplier, + Supplier clipboardReader, + Consumer clipboardWriter + ) { + this.sectionsSupplier = sectionsSupplier; + this.registrySupplier = registrySupplier; + this.componentRegistrySupplier = componentRegistrySupplier; + this.portTypes = portTypes; + this.uiConfigSupplier = uiConfigSupplier; + this.i18nSupplier = i18nSupplier; + this.searchField = new GraphTextFieldComponent( + () -> GraphEditorTranslations.ui(i18nSupplier.get(), "palette.search_placeholder", "Search nodes"), + clipboardReader, + clipboardWriter, + state::setQuery + ); + } + + public void init(Font textRenderer, GraphEditorBounds bounds) { + this.textRenderer = textRenderer; + setBounds(bounds); + searchField.setText(state.query()); + } + + public void setBounds(GraphEditorBounds bounds) { + this.bounds = bounds; + searchField.setBounds(searchFieldBounds()); + } + + public boolean isOpen() { + return state.open(); + } + + public void toggle() { + state.toggle(); + searchField.setFocused(false); + } + + public void blurSearch() { + searchField.setFocused(false); + } + + public boolean isSearchFocused() { + return state.open() && searchField.focused(); + } + + public int sidebarRight() { + return panelX() + PANEL_WIDTH; + } + + public boolean blocksCanvasAt(double mouseX, double mouseY) { + return state.open() && mouseX >= panelX() && mouseX <= panelX() + PANEL_WIDTH && mouseY >= panelY() && mouseY <= panelBottom(); + } + + public GraphCursorManager.CursorKind cursorKindAt(double mouseX, double mouseY) { + if (state.open()) { + if (searchField.contains(mouseX, mouseY)) { + return GraphCursorManager.CursorKind.TEXT; + } + Row row = rowAt(mouseX, mouseY); + if (row != null && row.type() == RowType.ENTRY) { + return GraphCursorManager.CursorKind.GRAB; + } + } + return GraphCursorManager.CursorKind.DEFAULT; + } + + public void render(GuiGraphicsExtractor context, Font textRenderer, int mouseX, int mouseY, float delta) { + this.textRenderer = textRenderer; + GraphEditorUiConfig uiConfig = uiConfigSupplier.get(); + GraphEditorTheme theme = uiConfig.theme(); + GraphEditorI18n i18n = i18nSupplier.get(); + renderToggleButton(context, textRenderer, uiConfig, theme); + if (!state.open()) { + return; + } + + EditorStyleRenderer.drawBox(context, panelX(), panelY(), PANEL_WIDTH, panelBottom() - panelY(), theme.panelBackgroundColor(), theme.panelBorderColor(), uiConfig); + context.text(textRenderer, GraphEditorTranslations.ui(i18n, "palette.title", "Nodes"), panelX() + PANEL_PADDING, panelY() + 10, theme.primaryTextColor(), false); + searchField.render(context, textRenderer, theme, uiConfig); + + int listTop = panelY() + 28 + SEARCH_HEIGHT + LIST_TOP_GAP; + int listBottom = panelBottom() - LIST_BOTTOM_PADDING; + List rows = visibleRows(); + double maxScroll = Math.max(0, totalContentHeight(rows) - (listBottom - listTop)); + state.setScrollOffset(Math.min(state.scrollOffset(), maxScroll)); + + context.enableScissor(panelX() + 1, listTop, panelX() + PANEL_WIDTH - 1, listBottom); + try { + int y = listTop - (int) Math.round(state.scrollOffset()); + for (Row row : rows) { + if (row.type() == RowType.SECTION) { + if (y + SECTION_HEADER_HEIGHT >= listTop && y <= listBottom) { + context.text(textRenderer, row.title(), panelX() + PANEL_PADDING, y + 4, theme.accentColor(), false); + } + y += SECTION_HEADER_HEIGHT; + continue; + } + + if (y + ENTRY_HEIGHT >= listTop && y <= listBottom) { + boolean hovered = mouseX >= panelX() + PANEL_PADDING && mouseX <= panelX() + PANEL_WIDTH - PANEL_PADDING && mouseY >= y && mouseY <= y + ENTRY_HEIGHT; + boolean pressed = row.entry().equals(state.pressedEntry()) && !state.dragging(); + int fill = pressed + ? EditorStyleRenderer.blend(theme.nodeBodyColor(), theme.accentColor(), 0.24f) + : hovered ? EditorStyleRenderer.brighten(theme.nodeBodyColor(), 0.08f) : theme.nodeBodyColor(); + EditorStyleRenderer.drawBox(context, panelX() + PANEL_PADDING, y, PANEL_WIDTH - (PANEL_PADDING * 2), ENTRY_HEIGHT, fill, theme.panelBorderColor(), uiConfig); + context.text(textRenderer, row.entry().displayName(), panelX() + PANEL_PADDING + 6, y + 5, theme.primaryTextColor(), false); + context.text(textRenderer, row.entry().subtitle(), panelX() + PANEL_PADDING + 6, y + 14, theme.secondaryTextColor(), false); + } + y += ENTRY_HEIGHT; + } + } finally { + context.disableScissor(); + } + + renderScrollbar(context, rows, listTop, listBottom, uiConfig, theme); + } + + public boolean mouseClicked(double mouseX, double mouseY, int button) { + if (button == 0 && insideToggleButton(mouseX, mouseY)) { + toggle(); + return true; + } + if (!state.open()) { + return false; + } + if (!bounds.contains(mouseX, mouseY)) { + searchField.setFocused(false); + return false; + } + + boolean searchHandled = searchField.mouseClicked(mouseX, mouseY, button, textRenderer); + if (searchHandled) { + return true; + } + if (!blocksCanvasAt(mouseX, mouseY)) { + return false; + } + if (button != 0) { + return true; + } + + Row row = rowAt(mouseX, mouseY); + if (row != null && row.type() == RowType.ENTRY) { + state.setPressedEntry(row.entry(), mouseX, mouseY); + } + return true; + } + + public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) { + if (searchField.mouseDragged(mouseX, button, textRenderer)) { + return true; + } + if (button != 0 || !state.open()) { + return false; + } + + if (state.pressedEntry() != null) { + double dx = mouseX - state.pressedMouseX(); + double dy = mouseY - state.pressedMouseY(); + if (!state.dragging() && ((dx * dx) + (dy * dy)) >= (DRAG_THRESHOLD * DRAG_THRESHOLD)) { + state.startDragging(state.pressedEntry()); + } + if (state.dragging()) { + state.updateDragMouse(mouseX, mouseY); + } + return true; + } + + return blocksCanvasAt(mouseX, mouseY); + } + + public NodePaletteInteractionResult mouseReleased(double mouseX, double mouseY, int button) { + searchField.mouseReleased(button); + if (button != 0) { + return state.open() && blocksCanvasAt(mouseX, mouseY) ? NodePaletteInteractionResult.handledResult() : NodePaletteInteractionResult.ignoredResult(); + } + if (!state.open()) { + return NodePaletteInteractionResult.ignoredResult(); + } + + try { + if (state.dragging() && state.dragEntry() != null) { + if (!blocksCanvasAt(mouseX, mouseY) && bounds.contains(mouseX, mouseY)) { + return NodePaletteInteractionResult.actionResult(new NodePaletteAction.CreateAtPointer(state.dragEntry().nodeTypeId(), mouseX, mouseY)); + } + return NodePaletteInteractionResult.handledResult(); + } + if (state.pressedEntry() != null) { + Row row = rowAt(mouseX, mouseY); + if (row != null && row.type() == RowType.ENTRY && row.entry().equals(state.pressedEntry())) { + return NodePaletteInteractionResult.actionResult(new NodePaletteAction.CreateAtCenter(row.entry().nodeTypeId())); + } + return NodePaletteInteractionResult.handledResult(); + } + return blocksCanvasAt(mouseX, mouseY) ? NodePaletteInteractionResult.handledResult() : NodePaletteInteractionResult.ignoredResult(); + } finally { + state.clearInteraction(); + } + } + + public boolean mouseScrolled(double mouseX, double mouseY, double horizontalAmount, double verticalAmount) { + if (!state.open() || !blocksCanvasAt(mouseX, mouseY)) { + return false; + } + double scrollAmount = resolveScrollAmount(horizontalAmount, verticalAmount); + if (scrollAmount == 0.0) { + return true; + } + List rows = visibleRows(); + int listTop = panelY() + 28 + SEARCH_HEIGHT + LIST_TOP_GAP; + int listBottom = panelBottom() - LIST_BOTTOM_PADDING; + double maxScroll = Math.max(0, totalContentHeight(rows) - (listBottom - listTop)); + double magnitude = Math.max(1.0, Math.abs(scrollAmount)); + double next = state.scrollOffset() - (Math.signum(scrollAmount) * magnitude * SCROLL_STEP); + state.setScrollOffset(Math.max(0, Math.min(maxScroll, next))); + return true; + } + + public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + return state.open() && searchField.keyPressed(keyCode, scanCode, modifiers, textRenderer); + } + + public boolean charTyped(char chr, int modifiers) { + return state.open() && searchField.charTyped(chr, modifiers, textRenderer); + } + + public void renderDragPreview(GuiGraphicsExtractor context, Font textRenderer) { + if (!state.dragging() || state.dragEntry() == null) { + return; + } + GraphEditorUiConfig uiConfig = uiConfigSupplier.get(); + GraphEditorTheme theme = uiConfig.theme(); + NodeType nodeType = registrySupplier.get().getOrThrow(state.dragEntry().nodeTypeId()); + NodeComponentDefinition definition = componentRegistrySupplier.get().find(nodeType.id()).orElse(null); + NodeBodyComponent component = definition == null ? null : definition.factory().create(); + ResizePolicy resizePolicy = definition == null ? ResizePolicy.none() : definition.resizePolicy(); + try { + NodeWidget widget = NodeWidget.preview(nodeType, uiConfig, i18nSupplier.get(), component, resizePolicy, (int) Math.round(state.dragMouseX()), (int) Math.round(state.dragMouseY())); + NodeWidgetRenderer.render( + context, + textRenderer, + widget, + uiConfig, + theme, + false, + false, + false, + port -> port.definition().channel() == PortChannel.CONTROL ? theme.controlFlowColor() : portTypes.colorOf(port.definition().type()), + port -> false, + (nodeId, portId) -> false, + (nodeId, key) -> false + ); + } finally { + if (component != null) { + component.close(); + } + } + } + + private void renderToggleButton(GuiGraphicsExtractor context, Font textRenderer, GraphEditorUiConfig uiConfig, GraphEditorTheme theme) { + int fill = state.open() ? EditorStyleRenderer.blend(theme.panelBackgroundColor(), theme.accentColor(), 0.18f) : theme.panelBackgroundColor(); + EditorStyleRenderer.drawBox(context, toggleX(), toggleY(), TOGGLE_WIDTH, TOGGLE_HEIGHT, fill, theme.panelBorderColor(), uiConfig); + context.text( + textRenderer, + state.open() + ? GraphEditorTranslations.ui(i18nSupplier.get(), "palette.toggle_open", "Nodes [%s]", GraphInputText.key(GLFW.GLFW_KEY_TAB)) + : GraphEditorTranslations.ui(i18nSupplier.get(), "palette.toggle_closed", "Open [%s]", GraphInputText.key(GLFW.GLFW_KEY_TAB)), + toggleX() + 8, + toggleY() + 6, + theme.primaryTextColor(), + false + ); + } + + private void renderScrollbar(GuiGraphicsExtractor context, List rows, int listTop, int listBottom, GraphEditorUiConfig uiConfig, GraphEditorTheme theme) { + int viewportHeight = listBottom - listTop; + int contentHeight = totalContentHeight(rows); + if (contentHeight <= viewportHeight) { + return; + } + int trackX = panelX() + PANEL_WIDTH - 8; + EditorStyleRenderer.drawBox(context, trackX, listTop, 4, viewportHeight, EditorStyleRenderer.darken(theme.panelBackgroundColor(), 0.15f), theme.panelBorderColor(), uiConfig); + double thumbRatio = viewportHeight / (double) contentHeight; + int thumbHeight = Math.max(18, (int) Math.round(viewportHeight * thumbRatio)); + double maxScroll = contentHeight - viewportHeight; + double scrollRatio = maxScroll <= 0 ? 0 : state.scrollOffset() / maxScroll; + int thumbY = listTop + (int) Math.round((viewportHeight - thumbHeight) * scrollRatio); + EditorStyleRenderer.drawBox(context, trackX - 1, thumbY, 6, thumbHeight, theme.accentColor(), theme.panelBorderColor(), uiConfig); + } + + private boolean insideToggleButton(double mouseX, double mouseY) { + return mouseX >= toggleX() && mouseX <= toggleX() + TOGGLE_WIDTH && mouseY >= toggleY() && mouseY <= toggleY() + TOGGLE_HEIGHT; + } + + private List filteredSections() { + return NodePaletteCatalog.filterSections(sectionsSupplier.get(), state.query()); + } + + private List visibleRows() { + List rows = new ArrayList<>(); + for (NodePaletteSection section : filteredSections()) { + rows.add(Row.section(section.title())); + section.entries().forEach(entry -> rows.add(Row.entry(entry))); + } + return rows; + } + + private int totalContentHeight(List rows) { + int total = 0; + for (Row row : rows) { + total += row.type() == RowType.SECTION ? SECTION_HEADER_HEIGHT : ENTRY_HEIGHT; + } + return total; + } + + private static double resolveScrollAmount(double horizontalAmount, double verticalAmount) { + if (Math.abs(verticalAmount) >= Math.abs(horizontalAmount)) { + return verticalAmount; + } + return horizontalAmount; + } + + private Row rowAt(double mouseX, double mouseY) { + if (!blocksCanvasAt(mouseX, mouseY)) { + return null; + } + int listTop = panelY() + 28 + SEARCH_HEIGHT + LIST_TOP_GAP; + int listBottom = panelBottom() - LIST_BOTTOM_PADDING; + if (mouseY < listTop || mouseY > listBottom) { + return null; + } + int y = listTop - (int) Math.round(state.scrollOffset()); + for (Row row : visibleRows()) { + int rowHeight = row.type() == RowType.SECTION ? SECTION_HEADER_HEIGHT : ENTRY_HEIGHT; + if (row.type() == RowType.ENTRY + && mouseX >= panelX() + PANEL_PADDING + && mouseX <= panelX() + PANEL_WIDTH - PANEL_PADDING + && mouseY >= y + && mouseY <= y + rowHeight) { + return row; + } + y += rowHeight; + } + return null; + } + + private int toggleX() { + return bounds.x() + TOGGLE_MARGIN_X; + } + + private int toggleY() { + return bounds.y() + TOGGLE_MARGIN_Y; + } + + private int panelX() { + return bounds.x() + PANEL_MARGIN_X; + } + + private int panelY() { + return bounds.y() + PANEL_TOP; + } + + private int panelBottom() { + return bounds.bottom() - 10; + } + + private NodeWidget.Bounds searchFieldBounds() { + return new NodeWidget.Bounds(panelX() + PANEL_PADDING, panelY() + 28, PANEL_WIDTH - (PANEL_PADDING * 2), SEARCH_HEIGHT); + } + + private enum RowType { + SECTION, + ENTRY + } + + private record Row(RowType type, String title, NodePaletteEntry entry) { + static Row section(String title) { + return new Row(RowType.SECTION, title, null); + } + + static Row entry(NodePaletteEntry entry) { + return new Row(RowType.ENTRY, null, entry); + } + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodePaletteDefinition.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodePaletteDefinition.java new file mode 100644 index 0000000..c9ed3ab --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodePaletteDefinition.java @@ -0,0 +1,40 @@ +package com.github.squi2rel.mcng.fabric.client; + +import java.util.List; +import java.util.Objects; + +public record NodePaletteDefinition( + String nodeTypeId, + String sectionTitle, + int sectionOrder, + int itemOrder, + List searchKeywords, + String sectionTranslationKey +) { + public NodePaletteDefinition { + Objects.requireNonNull(nodeTypeId, "nodeTypeId"); + Objects.requireNonNull(sectionTitle, "sectionTitle"); + searchKeywords = List.copyOf(Objects.requireNonNull(searchKeywords, "searchKeywords")); + if (nodeTypeId.isBlank()) { + throw new IllegalArgumentException("nodeTypeId must not be blank"); + } + if (sectionTitle.isBlank()) { + throw new IllegalArgumentException("sectionTitle must not be blank"); + } + if (sectionTranslationKey != null && sectionTranslationKey.isBlank()) { + throw new IllegalArgumentException("sectionTranslationKey must not be blank"); + } + } + + public NodePaletteDefinition(String nodeTypeId, String sectionTitle, int sectionOrder, int itemOrder) { + this(nodeTypeId, sectionTitle, sectionOrder, itemOrder, List.of(), null); + } + + public NodePaletteDefinition(String nodeTypeId, String sectionTitle, int sectionOrder, int itemOrder, List searchKeywords) { + this(nodeTypeId, sectionTitle, sectionOrder, itemOrder, searchKeywords, null); + } + + public NodePaletteDefinition(String nodeTypeId, String sectionTitle, String sectionTranslationKey, int sectionOrder, int itemOrder, List searchKeywords) { + this(nodeTypeId, sectionTitle, sectionOrder, itemOrder, searchKeywords, sectionTranslationKey); + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodePaletteEntry.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodePaletteEntry.java new file mode 100644 index 0000000..ff2f73c --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodePaletteEntry.java @@ -0,0 +1,14 @@ +package com.github.squi2rel.mcng.fabric.client; + +import java.util.List; +import java.util.Objects; + +record NodePaletteEntry(String nodeTypeId, String displayName, String subtitle, String groupName, int itemOrder, List searchKeywords) { + NodePaletteEntry { + Objects.requireNonNull(nodeTypeId, "nodeTypeId"); + Objects.requireNonNull(displayName, "displayName"); + Objects.requireNonNull(subtitle, "subtitle"); + Objects.requireNonNull(groupName, "groupName"); + searchKeywords = List.copyOf(Objects.requireNonNull(searchKeywords, "searchKeywords")); + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodePaletteInteractionResult.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodePaletteInteractionResult.java new file mode 100644 index 0000000..de9ac0f --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodePaletteInteractionResult.java @@ -0,0 +1,15 @@ +package com.github.squi2rel.mcng.fabric.client; + +record NodePaletteInteractionResult(boolean handled, NodePaletteAction action) { + static NodePaletteInteractionResult handledResult() { + return new NodePaletteInteractionResult(true, null); + } + + static NodePaletteInteractionResult ignoredResult() { + return new NodePaletteInteractionResult(false, null); + } + + static NodePaletteInteractionResult actionResult(NodePaletteAction action) { + return new NodePaletteInteractionResult(true, action); + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodePaletteRegistry.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodePaletteRegistry.java new file mode 100644 index 0000000..b781ee1 --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodePaletteRegistry.java @@ -0,0 +1,29 @@ +package com.github.squi2rel.mcng.fabric.client; + +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +public final class NodePaletteRegistry { + private final Map definitions = new LinkedHashMap<>(); + + public synchronized NodePaletteRegistry register(NodePaletteDefinition definition) { + Objects.requireNonNull(definition, "definition"); + NodePaletteDefinition existing = definitions.putIfAbsent(definition.nodeTypeId(), definition); + if (existing != null) { + throw new IllegalArgumentException("Duplicate palette node type id: " + definition.nodeTypeId()); + } + return this; + } + + public synchronized Optional find(String nodeTypeId) { + return Optional.ofNullable(definitions.get(nodeTypeId)); + } + + public synchronized Collection all() { + return List.copyOf(definitions.values()); + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodePaletteSection.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodePaletteSection.java new file mode 100644 index 0000000..99ff009 --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodePaletteSection.java @@ -0,0 +1,12 @@ +package com.github.squi2rel.mcng.fabric.client; + +import java.util.List; +import java.util.Objects; + +record NodePaletteSection(String title, int order, List entries) { + NodePaletteSection { + Objects.requireNonNull(title, "title"); + Objects.requireNonNull(entries, "entries"); + entries = List.copyOf(entries); + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodePaletteState.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodePaletteState.java new file mode 100644 index 0000000..69fa370 --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodePaletteState.java @@ -0,0 +1,96 @@ +package com.github.squi2rel.mcng.fabric.client; + +final class NodePaletteState { + private boolean open; + private String query = ""; + private double scrollOffset; + private NodePaletteEntry pressedEntry; + private double pressedMouseX; + private double pressedMouseY; + private NodePaletteEntry dragEntry; + private boolean dragging; + private double dragMouseX; + private double dragMouseY; + + boolean open() { + return open; + } + + void setOpen(boolean open) { + this.open = open; + if (!open) { + clearInteraction(); + } + } + + void toggle() { + setOpen(!open); + } + + String query() { + return query; + } + + void setQuery(String query) { + this.query = query; + scrollOffset = 0; + } + + double scrollOffset() { + return scrollOffset; + } + + void setScrollOffset(double scrollOffset) { + this.scrollOffset = Math.max(0, scrollOffset); + } + + NodePaletteEntry pressedEntry() { + return pressedEntry; + } + + void setPressedEntry(NodePaletteEntry pressedEntry, double mouseX, double mouseY) { + this.pressedEntry = pressedEntry; + this.pressedMouseX = mouseX; + this.pressedMouseY = mouseY; + } + + double pressedMouseX() { + return pressedMouseX; + } + + double pressedMouseY() { + return pressedMouseY; + } + + NodePaletteEntry dragEntry() { + return dragEntry; + } + + void startDragging(NodePaletteEntry dragEntry) { + this.dragEntry = dragEntry; + this.dragging = true; + } + + boolean dragging() { + return dragging; + } + + void updateDragMouse(double mouseX, double mouseY) { + this.dragMouseX = mouseX; + this.dragMouseY = mouseY; + } + + double dragMouseX() { + return dragMouseX; + } + + double dragMouseY() { + return dragMouseY; + } + + void clearInteraction() { + pressedEntry = null; + dragEntry = null; + dragging = false; + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeRenderDetailLevel.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeRenderDetailLevel.java new file mode 100644 index 0000000..367f1d4 --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeRenderDetailLevel.java @@ -0,0 +1,12 @@ +package com.github.squi2rel.mcng.fabric.client; + +public enum NodeRenderDetailLevel { + FULL, + MINIMAL; + + private static final double MINIMAL_EPSILON = 1.0E-6; + + public static NodeRenderDetailLevel fromZoom(double zoom) { + return zoom <= GraphViewportState.MIN_ZOOM + MINIMAL_EPSILON ? MINIMAL : FULL; + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeWidget.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeWidget.java new file mode 100644 index 0000000..745eea0 --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeWidget.java @@ -0,0 +1,903 @@ +package com.github.squi2rel.mcng.fabric.client; + +import com.github.squi2rel.mcng.core.DocumentNodeTypes; +import com.github.squi2rel.mcng.core.GraphLayout; +import com.github.squi2rel.mcng.core.NodeEditorControl; +import com.github.squi2rel.mcng.core.NodeId; +import com.github.squi2rel.mcng.core.NodeInstance; +import com.github.squi2rel.mcng.core.NodePosition; +import com.github.squi2rel.mcng.core.NodeSize; +import com.github.squi2rel.mcng.core.NodeType; +import com.github.squi2rel.mcng.core.NodeVisualStyle; +import com.github.squi2rel.mcng.core.PortChannel; +import com.github.squi2rel.mcng.core.PortDefinition; +import com.github.squi2rel.mcng.core.PortDirection; +import com.github.squi2rel.mcng.core.PortId; +import com.github.squi2rel.mcng.core.PortInlineWidget; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.BiPredicate; +import java.util.function.Function; + +public final class NodeWidget { + private static final int BASE_WIDTH = 196; + private static final int BASE_HEADER_HEIGHT = 22; + private static final int BASE_EDGE_PADDING = 8; + private static final int BASE_PORT_RADIUS = 4; + private static final int BASE_PORT_HIT_RADIUS = 6; + private static final int BASE_ROW_HEIGHT = 20; + private static final int BASE_ROW_GAP = 2; + private static final int BASE_GROUP_GAP = 6; + private static final int BASE_BODY_TOP_PADDING = 8; + private static final int BASE_BODY_BOTTOM_PADDING = 8; + private static final int BASE_EMPTY_BODY_HEIGHT = 20; + private static final int BASE_FIELD_WIDTH = 86; + private static final int BASE_ARROW_WIDTH = 15; + private static final int BASE_REROUTE_WIDTH = 56; + private static final int BASE_REROUTE_HEIGHT = 16; + private static final int BASE_CUSTOM_BODY_GAP = 6; + private static final int BASE_RESIZE_EDGE = 6; + private static final int BASE_RESIZE_CORNER = 10; + + private final NodeInstance node; + private final NodeType nodeType; + private final NodeBodyComponent bodyComponent; + private final ResizePolicy resizePolicy; + private final Optional session; + private final GraphEditorI18n i18n; + private final GraphEditorUiConfig uiConfig; + private final boolean preview; + private final double zoom; + private final NodeRenderDetailLevel detailLevel; + private final int x; + private final int y; + private final int width; + private final int height; + private final int minWidth; + private final int minHeight; + private final int headerHeight; + private final boolean embeddedHeaderControl; + private final int edgePadding; + private final boolean compactReroute; + private final RerouteOrientation rerouteOrientation; + private final List channelSeparators; + private final List ports; + private final List rows; + private final Bounds bodyBounds; + + public NodeWidget(NodeInstance node, NodeType nodeType, GraphEditorSession session, GraphLayout layout, GraphViewportState viewport) { + this(node, nodeType, session, layout, viewport, GraphEditorUiConfig.defaultConfig(), null, ResizePolicy.none()); + } + + public NodeWidget( + NodeInstance node, + NodeType nodeType, + GraphEditorSession session, + GraphLayout layout, + GraphViewportState viewport, + GraphEditorUiConfig uiConfig, + NodeBodyComponent bodyComponent, + ResizePolicy resizePolicy + ) { + this( + node, + nodeType, + layout, + viewport, + uiConfig, + Optional.of(session), + session.i18n(), + bodyComponent, + resizePolicy, + false, + session::hasIncomingConnection, + session::rerouteOrientation + ); + } + + private NodeWidget( + NodeInstance node, + NodeType nodeType, + GraphLayout layout, + GraphViewportState viewport, + GraphEditorUiConfig uiConfig, + Optional session, + GraphEditorI18n i18n, + NodeBodyComponent bodyComponent, + ResizePolicy resizePolicy, + boolean preview, + BiPredicate hasIncomingConnection, + Function rerouteOrientationProvider + ) { + this.node = node; + this.nodeType = nodeType; + this.bodyComponent = bodyComponent; + this.resizePolicy = resizePolicy == null ? ResizePolicy.none() : resizePolicy; + this.session = session; + this.i18n = i18n == null ? GraphEditorI18n.identity() : i18n; + this.uiConfig = uiConfig; + this.preview = preview; + this.zoom = viewport.zoom(); + this.detailLevel = NodeRenderDetailLevel.fromZoom(viewport.zoom()); + + NodePosition position = layout.nodePositions().getOrDefault(node.id(), new NodePosition(0, 0)); + NodeSize storedSize = layout.nodeSizes().get(node.id()); + this.x = (int) Math.round(position.x()); + this.y = (int) Math.round(position.y()); + this.compactReroute = nodeType.visualStyle() == NodeVisualStyle.COMPACT_REROUTE; + this.rerouteOrientation = compactReroute ? rerouteOrientationProvider.apply(node.id()) : RerouteOrientation.LEFT_TO_RIGHT; + this.embeddedHeaderControl = !compactReroute + && (DocumentNodeTypes.isSubgraphType(node.typeId()) || DocumentNodeTypes.isDefinitionType(node.typeId())); + this.headerHeight = compactReroute ? 0 : BASE_HEADER_HEIGHT; + this.edgePadding = BASE_EDGE_PADDING; + + if (compactReroute) { + this.width = this.rerouteOrientation.vertical() + ? compactRerouteThickness() + : Math.max(compactRerouteMinimumLength(), storedSize == null ? compactRerouteMinimumLength() : storedSize.width()); + this.height = this.rerouteOrientation.vertical() + ? Math.max(compactRerouteMinimumLength(), storedSize == null ? compactRerouteThickness() : storedSize.height()) + : compactRerouteThickness(); + this.minWidth = this.rerouteOrientation.vertical() ? compactRerouteThickness() : compactRerouteMinimumLength(); + this.minHeight = this.rerouteOrientation.vertical() ? compactRerouteMinimumLength() : compactRerouteThickness(); + this.ports = buildReroutePorts(this.rerouteOrientation); + this.channelSeparators = List.of(); + this.rows = List.of(); + this.bodyBounds = null; + return; + } + + RequestedNodeSize requestedSize = requestedNodeSize(storedSize); + NodeBodyMeasurement bodyMeasurement = measureBody(requestedSize.width() - (BASE_EDGE_PADDING * 2), viewport.zoom()); + int calculatedMinWidth = Math.max(BASE_WIDTH, bodyMeasurement.visible() ? bodyMeasurement.minWidth() + (BASE_EDGE_PADDING * 2) : BASE_WIDTH); + int provisionalWidth = Math.max(requestedSize.width(), calculatedMinWidth); + if (storedSize == null && bodyMeasurement.visible()) { + provisionalWidth = Math.max(provisionalWidth, bodyMeasurement.preferredWidth() + (BASE_EDGE_PADDING * 2)); + } + if (provisionalWidth != requestedSize.width()) { + bodyMeasurement = measureBody(provisionalWidth - (BASE_EDGE_PADDING * 2), viewport.zoom()); + calculatedMinWidth = Math.max(BASE_WIDTH, bodyMeasurement.visible() ? bodyMeasurement.minWidth() + (BASE_EDGE_PADDING * 2) : BASE_WIDTH); + provisionalWidth = Math.max(provisionalWidth, calculatedMinWidth); + } + this.width = provisionalWidth; + + FullLayout layoutMetrics = buildFullLayout(hasIncomingConnection, bodyMeasurement, requestedSize.height()); + this.ports = layoutMetrics.ports(); + this.channelSeparators = layoutMetrics.channelSeparators(); + this.rows = layoutMetrics.rows(); + this.bodyBounds = layoutMetrics.bodyBounds(); + this.height = layoutMetrics.height(); + this.minWidth = calculatedMinWidth; + this.minHeight = layoutMetrics.minHeight(); + } + + public static NodeWidget preview( + NodeType nodeType, + int centerX, + int centerY + ) { + return preview(nodeType, GraphEditorUiConfig.defaultConfig(), GraphEditorI18n.identity(), null, ResizePolicy.none(), centerX, centerY); + } + + public static NodeWidget preview( + NodeType nodeType, + GraphEditorUiConfig uiConfig, + NodeBodyComponent bodyComponent, + ResizePolicy resizePolicy, + int centerX, + int centerY + ) { + return preview(nodeType, uiConfig, GraphEditorI18n.identity(), bodyComponent, resizePolicy, centerX, centerY); + } + + public static NodeWidget preview( + NodeType nodeType, + GraphEditorUiConfig uiConfig, + GraphEditorI18n i18n, + NodeBodyComponent bodyComponent, + ResizePolicy resizePolicy, + int centerX, + int centerY + ) { + NodeInstance previewNode = createPreviewNode(nodeType); + GraphViewportState viewport = new GraphViewportState(); + NodeWidget provisional = new NodeWidget( + previewNode, + nodeType, + new GraphLayout(Map.of(previewNode.id(), new NodePosition(0, 0))), + viewport, + uiConfig, + Optional.empty(), + i18n, + bodyComponent, + resizePolicy, + true, + (nodeId, portId) -> false, + nodeId -> RerouteOrientation.LEFT_TO_RIGHT + ); + return new NodeWidget( + previewNode, + nodeType, + new GraphLayout(Map.of(previewNode.id(), new NodePosition(centerX - (provisional.width() / 2), centerY - (provisional.height() / 2)))), + viewport, + uiConfig, + Optional.empty(), + i18n, + bodyComponent, + resizePolicy, + true, + (nodeId, portId) -> false, + nodeId -> RerouteOrientation.LEFT_TO_RIGHT + ); + } + + public NodeInstance node() { + return node; + } + + public NodeType nodeType() { + return nodeType; + } + + public NodeBodyComponent bodyComponent() { + return bodyComponent; + } + + public ResizePolicy resizePolicy() { + return resizePolicy; + } + + public Optional session() { + return session; + } + + public GraphEditorUiConfig uiConfig() { + return uiConfig; + } + + public GraphEditorI18n i18n() { + return i18n; + } + + public boolean preview() { + return preview; + } + + public double zoom() { + return zoom; + } + + public int x() { + return x; + } + + public int y() { + return y; + } + + public int width() { + return width; + } + + public int height() { + return height; + } + + public int minWidth() { + return minWidth; + } + + public int minHeight() { + return minHeight; + } + + public int headerHeight() { + return headerHeight; + } + + public boolean hasHeader() { + return headerHeight > 0; + } + + public boolean showHeaderTitle() { + return hasHeader() && !embeddedHeaderControl; + } + + public int edgePadding() { + return edgePadding; + } + + public boolean compactReroute() { + return compactReroute; + } + + public RerouteOrientation rerouteOrientation() { + return rerouteOrientation; + } + + public NodeRenderDetailLevel detailLevel() { + return detailLevel; + } + + public Bounds bodyBounds() { + return bodyBounds; + } + + public boolean hasVisibleBody() { + return bodyBounds != null; + } + + public boolean hasInteractiveBody() { + return bodyComponent != null && bodyBounds != null && detailLevel == NodeRenderDetailLevel.FULL; + } + + public boolean contains(double mouseX, double mouseY) { + return mouseX >= x && mouseX <= x + width && mouseY >= y && mouseY <= y + height; + } + + public PortWidget findPortAt(double mouseX, double mouseY) { + for (PortWidget port : ports) { + if (port.contains(mouseX, mouseY)) { + return port; + } + } + return null; + } + + public ResizeHandle findResizeHandleAt(double mouseX, double mouseY) { + if (!contains(mouseX, mouseY)) { + return null; + } + if (compactReroute) { + return findCompactRerouteResizeHandleAt(mouseX, mouseY); + } + if (bodyComponent == null || !resizePolicy.resizable()) { + return null; + } + boolean left = resizePolicy.allowLeft() && mouseX <= x + BASE_RESIZE_CORNER; + boolean right = resizePolicy.allowRight() && mouseX >= (x + width) - BASE_RESIZE_CORNER; + boolean top = resizePolicy.allowTop() && mouseY <= y + BASE_RESIZE_CORNER; + boolean bottom = resizePolicy.allowBottom() && mouseY >= (y + height) - BASE_RESIZE_CORNER; + if (left && top && resizePolicy.supports(ResizeDirection.TOP_LEFT)) { + return new ResizeHandle(ResizeDirection.TOP_LEFT); + } + if (right && top && resizePolicy.supports(ResizeDirection.TOP_RIGHT)) { + return new ResizeHandle(ResizeDirection.TOP_RIGHT); + } + if (left && bottom && resizePolicy.supports(ResizeDirection.BOTTOM_LEFT)) { + return new ResizeHandle(ResizeDirection.BOTTOM_LEFT); + } + if (right && bottom && resizePolicy.supports(ResizeDirection.BOTTOM_RIGHT)) { + return new ResizeHandle(ResizeDirection.BOTTOM_RIGHT); + } + if (resizePolicy.allowLeft() && mouseX <= x + BASE_RESIZE_EDGE) { + return new ResizeHandle(ResizeDirection.LEFT); + } + if (resizePolicy.allowRight() && mouseX >= (x + width) - BASE_RESIZE_EDGE) { + return new ResizeHandle(ResizeDirection.RIGHT); + } + if (resizePolicy.allowTop() && mouseY <= y + BASE_RESIZE_EDGE) { + return new ResizeHandle(ResizeDirection.TOP); + } + if (resizePolicy.allowBottom() && mouseY >= (y + height) - BASE_RESIZE_EDGE) { + return new ResizeHandle(ResizeDirection.BOTTOM); + } + return null; + } + + private ResizeHandle findCompactRerouteResizeHandleAt(double mouseX, double mouseY) { + int cornerSpan = 4; + boolean leftCorner = mouseX <= x + cornerSpan; + boolean rightCorner = mouseX >= (x + width) - cornerSpan; + boolean topCorner = mouseY <= y + cornerSpan; + boolean bottomCorner = mouseY >= (y + height) - cornerSpan; + if (leftCorner && topCorner) { + return new ResizeHandle(ResizeDirection.TOP_LEFT); + } + if (rightCorner && topCorner) { + return new ResizeHandle(ResizeDirection.TOP_RIGHT); + } + if (leftCorner && bottomCorner) { + return new ResizeHandle(ResizeDirection.BOTTOM_LEFT); + } + if (rightCorner && bottomCorner) { + return new ResizeHandle(ResizeDirection.BOTTOM_RIGHT); + } + if (rerouteOrientation.vertical()) { + if (mouseY <= y + BASE_RESIZE_EDGE) { + return new ResizeHandle(ResizeDirection.TOP); + } + if (mouseY >= (y + height) - BASE_RESIZE_EDGE) { + return new ResizeHandle(ResizeDirection.BOTTOM); + } + return null; + } + if (mouseX <= x + BASE_RESIZE_EDGE) { + return new ResizeHandle(ResizeDirection.LEFT); + } + if (mouseX >= (x + width) - BASE_RESIZE_EDGE) { + return new ResizeHandle(ResizeDirection.RIGHT); + } + return null; + } + + public InlineHit findInlineHitAt(double mouseX, double mouseY) { + if (detailLevel != NodeRenderDetailLevel.FULL) { + return null; + } + for (RowWidget row : rows) { + switch (row) { + case InputPortRowWidget inputRow -> { + if (inputRow.fieldBounds() == null || !inputRow.fieldBounds().contains(mouseX, mouseY)) { + continue; + } + PortInlineWidget inlineWidget = inputRow.port().definition().inlineWidget(); + if (inlineWidget == null) { + continue; + } + return switch (inlineWidget.kind()) { + case NUMERIC_TEXT, STRING_TEXT -> new InlineHit.PortTextFieldHit(node.id(), inputRow.port().definition().id(), inputRow.fieldBounds(), inlineWidget.kind() == PortInlineWidget.Kind.NUMERIC_TEXT); + case BOOLEAN_TOGGLE -> new InlineHit.PortBooleanHit(node.id(), inputRow.port().definition().id()); + }; + } + case TextControlRowWidget textRow -> { + if (textRow.fieldBounds().contains(mouseX, mouseY)) { + return new InlineHit.ControlTextFieldHit(node.id(), textRow.control().key(), textRow.fieldBounds(), false); + } + } + case NumericTextControlRowWidget numericTextRow -> { + if (numericTextRow.fieldBounds().contains(mouseX, mouseY)) { + return new InlineHit.ControlTextFieldHit(node.id(), numericTextRow.control().key(), numericTextRow.fieldBounds(), true); + } + } + case BooleanControlRowWidget booleanRow -> { + if (booleanRow.toggleBounds().contains(mouseX, mouseY)) { + return new InlineHit.ControlBooleanHit(node.id(), booleanRow.control().key()); + } + } + case CycleControlRowWidget cycleRow -> { + if (cycleRow.leftArrowBounds().contains(mouseX, mouseY)) { + return new InlineHit.ControlCycleHit(node.id(), cycleRow.control().key(), -1); + } + if (cycleRow.rightArrowBounds().contains(mouseX, mouseY)) { + return new InlineHit.ControlCycleHit(node.id(), cycleRow.control().key(), 1); + } + } + case OutputPortRowWidget ignored -> { + } + } + } + return null; + } + + public List ports() { + return ports; + } + + public List rows() { + return rows; + } + + public List channelSeparators() { + return channelSeparators; + } + + private RequestedNodeSize requestedNodeSize(NodeSize storedSize) { + if (storedSize != null) { + return new RequestedNodeSize(Math.max(BASE_WIDTH, storedSize.width()), Math.max(1, storedSize.height())); + } + return new RequestedNodeSize(BASE_WIDTH, 0); + } + + private NodeBodyMeasurement measureBody(int availableWidth, double zoom) { + if (bodyComponent == null) { + return NodeBodyMeasurement.hidden(); + } + int clampedAvailableWidth = Math.max(24, availableWidth); + NodeBodyMeasurement measurement = bodyComponent.measure(new NodeBodyMeasureContext( + node, + nodeType, + session, + i18n, + uiConfig, + uiConfig.theme(), + zoom, + preview, + clampedAvailableWidth + )); + return measurement == null ? NodeBodyMeasurement.hidden() : measurement; + } + + private FullLayout buildFullLayout(BiPredicate hasIncomingConnection, NodeBodyMeasurement bodyMeasurement, int requestedHeight) { + int portRadius = BASE_PORT_RADIUS; + int hitRadius = BASE_PORT_HIT_RADIUS; + int rowHeight = BASE_ROW_HEIGHT; + int rowGap = BASE_ROW_GAP; + int bodyTopPadding = BASE_BODY_TOP_PADDING; + int bodyBottomPadding = BASE_BODY_BOTTOM_PADDING; + int fieldWidth = BASE_FIELD_WIDTH; + + List builtPorts = new ArrayList<>(); + List builtRows = new ArrayList<>(); + int controlRight = x + width - edgePadding; + int contentStart = y + headerHeight + bodyTopPadding; + int rowY = contentStart; + appendHeaderControls(headerControls(), rowHeight, builtRows); + List inputPorts = nodeType.inputs(node); + List outputPorts = nodeType.outputs(node); + List controlInputs = inputPorts.stream().filter(port -> port.channel() == PortChannel.CONTROL).toList(); + List dataInputs = inputPorts.stream().filter(port -> port.channel() == PortChannel.DATA).toList(); + List controlOutputs = outputPorts.stream().filter(port -> port.channel() == PortChannel.CONTROL).toList(); + List dataOutputs = outputPorts.stream().filter(port -> port.channel() == PortChannel.DATA).toList(); + List separators = new ArrayList<>(); + + rowY = appendPortGroup(controlInputs, controlOutputs, hasIncomingConnection, controlRight, fieldWidth, rowHeight, rowGap, portRadius, hitRadius, builtPorts, builtRows, rowY); + if ((!controlInputs.isEmpty() || !controlOutputs.isEmpty()) && (!dataInputs.isEmpty() || !dataOutputs.isEmpty())) { + separators.add(rowY + ((BASE_GROUP_GAP - rowGap) / 2)); + rowY += BASE_GROUP_GAP; + } + rowY = appendPortGroup(dataInputs, dataOutputs, hasIncomingConnection, controlRight, fieldWidth, rowHeight, rowGap, portRadius, hitRadius, builtPorts, builtRows, rowY); + + boolean hasPortRows = rowY > contentStart; + int portBottom = hasPortRows ? rowY - rowGap : contentStart; + List bodyControls = bodyControls(); + int controlCount = bodyControls.size(); + int controlsHeight = controlCount == 0 ? 0 : (controlCount * rowHeight) + ((controlCount - 1) * rowGap); + boolean visibleBody = bodyMeasurement.visible(); + int bodyTop = visibleBody ? portBottom + (hasPortRows ? BASE_CUSTOM_BODY_GAP : 0) : portBottom; + int storedContentHeight = requestedHeight <= 0 ? -1 : Math.max(0, requestedHeight - headerHeight - bodyTopPadding - bodyBottomPadding); + int outsideBodyHeight = Math.max(0, bodyTop - contentStart) + (visibleBody && controlsHeight > 0 ? BASE_GROUP_GAP : 0) + controlsHeight; + int allocatedBodyHeight = 0; + if (visibleBody) { + allocatedBodyHeight = storedContentHeight > 0 + ? Math.max(bodyMeasurement.minHeight(), storedContentHeight - outsideBodyHeight) + : bodyMeasurement.preferredHeight(); + } + Bounds body = visibleBody ? new Bounds(x + edgePadding, bodyTop, Math.max(24, width - (edgePadding * 2)), allocatedBodyHeight) : null; + int controlsStartY = visibleBody + ? bodyTop + allocatedBodyHeight + (controlsHeight > 0 ? BASE_GROUP_GAP : 0) + : (hasPortRows ? rowY : contentStart); + int afterControlsY = appendControls(bodyControls, controlRight, fieldWidth, rowHeight, rowGap, builtRows, controlsStartY); + int rowsBottom = controlCount > 0 + ? afterControlsY - rowGap + : (hasPortRows ? rowY - rowGap : contentStart); + int contentBottom; + if (hasPortRows || controlCount > 0) { + contentBottom = Math.max(visibleBody ? bodyTop + allocatedBodyHeight : contentStart, rowsBottom); + } else if (visibleBody) { + contentBottom = bodyTop + allocatedBodyHeight; + } else { + contentBottom = contentStart + BASE_EMPTY_BODY_HEIGHT; + } + int minContentHeight = Math.max( + BASE_EMPTY_BODY_HEIGHT, + Math.max(0, portBottom - contentStart) + + (visibleBody ? ((hasPortRows ? BASE_CUSTOM_BODY_GAP : 0) + bodyMeasurement.minHeight()) : 0) + + (visibleBody && controlsHeight > 0 ? BASE_GROUP_GAP : 0) + + controlsHeight + ); + int finalHeight = headerHeight + bodyTopPadding + Math.max(minContentHeight, contentBottom - contentStart) + bodyBottomPadding; + int calculatedMinHeight = headerHeight + bodyTopPadding + minContentHeight + bodyBottomPadding; + return new FullLayout(List.copyOf(builtPorts), List.copyOf(builtRows), List.copyOf(separators), body, finalHeight, calculatedMinHeight); + } + + private int appendPortGroup( + List inputs, + List outputs, + BiPredicate hasIncomingConnection, + int controlRight, + int fieldWidth, + int rowHeight, + int rowGap, + int portRadius, + int hitRadius, + List builtPorts, + List builtRows, + int startY + ) { + int rowY = startY; + int inputIndex = 0; + int outputIndex = 0; + while (inputIndex < inputs.size() || outputIndex < outputs.size()) { + boolean advanceRow = false; + if (inputIndex < inputs.size()) { + PortDefinition input = inputs.get(inputIndex++); + boolean connected = hasIncomingConnection.test(node.id(), input.id()); + Bounds fieldBounds = inputFieldBounds(input, connected, controlRight, fieldWidth, rowY, rowHeight); + PortWidget inputWidget = new PortWidget(node.id(), input, PortSide.LEFT, x + edgePadding, rowY + (rowHeight / 2), portRadius, hitRadius); + builtPorts.add(inputWidget); + builtRows.add(new InputPortRowWidget(rowY, rowHeight, inputWidget, fieldBounds, connected)); + advanceRow = true; + + if (fieldBounds == null && outputIndex < outputs.size()) { + PortDefinition output = outputs.get(outputIndex++); + PortWidget outputWidget = new PortWidget(node.id(), output, PortSide.RIGHT, x + width - edgePadding, rowY + (rowHeight / 2), portRadius, hitRadius); + builtPorts.add(outputWidget); + builtRows.add(new OutputPortRowWidget(rowY, rowHeight, outputWidget)); + } + } else if (outputIndex < outputs.size()) { + PortDefinition output = outputs.get(outputIndex++); + PortWidget outputWidget = new PortWidget(node.id(), output, PortSide.RIGHT, x + width - edgePadding, rowY + (rowHeight / 2), portRadius, hitRadius); + builtPorts.add(outputWidget); + builtRows.add(new OutputPortRowWidget(rowY, rowHeight, outputWidget)); + advanceRow = true; + } + + if (advanceRow) { + rowY += rowHeight + rowGap; + } + } + return rowY; + } + + private void appendHeaderControls(List controls, int rowHeight, List builtRows) { + int fieldHeight = Math.max(10, rowHeight - 4); + int fieldY = y + Math.max(2, (headerHeight - fieldHeight) / 2); + for (NodeEditorControl control : controls) { + if (control instanceof NodeEditorControl.TextControl textControl) { + builtRows.add(new TextControlRowWidget( + y, + headerHeight, + textControl, + new Bounds(x + edgePadding, fieldY, width - (edgePadding * 2), fieldHeight), + false + )); + } + } + } + + private int appendControls( + List controls, + int controlRight, + int fieldWidth, + int rowHeight, + int rowGap, + List builtRows, + int startY + ) { + int rowY = startY; + int arrowWidth = BASE_ARROW_WIDTH; + for (NodeEditorControl control : controls) { + boolean fullWidth = fullWidthControl(control); + int controlX = fullWidth ? x + edgePadding : controlRight - fieldWidth; + int controlWidth = fullWidth ? width - (edgePadding * 2) : fieldWidth; + switch (control) { + case NodeEditorControl.TextControl textControl -> builtRows.add(new TextControlRowWidget( + rowY, + rowHeight, + textControl, + new Bounds(controlX, rowY + 2, controlWidth, Math.max(10, rowHeight - 4)), + !fullWidth + )); + case NodeEditorControl.NumericTextControl numericTextControl -> builtRows.add(new NumericTextControlRowWidget( + rowY, + rowHeight, + numericTextControl, + new Bounds(controlX, rowY + 2, controlWidth, Math.max(10, rowHeight - 4)) + )); + case NodeEditorControl.BooleanControl booleanControl -> builtRows.add(new BooleanControlRowWidget( + rowY, + rowHeight, + booleanControl, + new Bounds(controlX, rowY + 2, fieldWidth, Math.max(10, rowHeight - 4)) + )); + case NodeEditorControl.CycleControl cycleControl -> { + int controlHeight = Math.max(10, rowHeight - 4); + int valueWidth = Math.max(18, fieldWidth - (arrowWidth * 2)); + int cycleY = rowY + 2; + builtRows.add(new CycleControlRowWidget( + rowY, + rowHeight, + cycleControl, + new Bounds(controlX, cycleY, arrowWidth, controlHeight), + new Bounds(controlX + arrowWidth, cycleY, valueWidth, controlHeight), + new Bounds(controlX + arrowWidth + valueWidth, cycleY, arrowWidth, controlHeight) + )); + } + } + rowY += rowHeight + rowGap; + } + return rowY; + } + + private List headerControls() { + if (!embeddedHeaderControl) { + return List.of(); + } + return nodeType.editorControls().stream() + .filter(this::fullWidthControl) + .toList(); + } + + private List bodyControls() { + if (!embeddedHeaderControl) { + return nodeType.editorControls(); + } + return nodeType.editorControls().stream() + .filter(control -> !fullWidthControl(control)) + .toList(); + } + + private boolean fullWidthControl(NodeEditorControl control) { + return embeddedHeaderControl + && control instanceof NodeEditorControl.TextControl textControl + && DocumentNodeTypes.DEFINITION_NAME_CONTROL_KEY.equals(textControl.key()); + } + + private List buildReroutePorts(RerouteOrientation orientation) { + List widgets = new ArrayList<>(); + int portRadius = BASE_PORT_RADIUS; + int hitRadius = BASE_PORT_HIT_RADIUS; + + for (PortDefinition port : nodeType.inputs(node)) { + PortSide side = reroutePortSide(orientation, port.direction()); + widgets.add(new PortWidget(node.id(), port, side, reroutePortCenterX(side), reroutePortCenterY(side), portRadius, hitRadius)); + } + for (PortDefinition port : nodeType.outputs(node)) { + PortSide side = reroutePortSide(orientation, port.direction()); + widgets.add(new PortWidget(node.id(), port, side, reroutePortCenterX(side), reroutePortCenterY(side), portRadius, hitRadius)); + } + return List.copyOf(widgets); + } + + private PortSide reroutePortSide(RerouteOrientation orientation, PortDirection direction) { + return direction == PortDirection.INPUT ? orientation.inputSide() : orientation.outputSide(); + } + + private int reroutePortCenterX(PortSide side) { + return switch (side) { + case LEFT -> x + edgePadding; + case RIGHT -> x + width - edgePadding; + case TOP, BOTTOM -> x + (width / 2); + }; + } + + private int reroutePortCenterY(PortSide side) { + return switch (side) { + case LEFT, RIGHT -> y + (height / 2); + case TOP -> y + edgePadding; + case BOTTOM -> y + height - edgePadding; + }; + } + + private Bounds inputFieldBounds(PortDefinition port, boolean connected, int controlRight, int fieldWidth, int rowY, int rowHeight) { + if (connected || port.inlineWidget() == null) { + return null; + } + return new Bounds(controlRight - fieldWidth, rowY + 2, fieldWidth, Math.max(10, rowHeight - 4)); + } + + @SuppressWarnings("unchecked") + private static NodeInstance createPreviewNode(NodeType nodeType) { + NodeType typed = (NodeType) nodeType; + return new NodeInstance(new NodeId("__palette_preview__"), typed.id(), typed.configCodec().toJson(typed.defaultConfig())); + } + + static int compactRerouteMinimumLength() { + return BASE_REROUTE_WIDTH; + } + + static int compactRerouteThickness() { + return BASE_REROUTE_HEIGHT; + } + + public enum PortSide { + LEFT, + RIGHT, + TOP, + BOTTOM; + + public boolean isHorizontal() { + return this == LEFT || this == RIGHT; + } + + public boolean isVertical() { + return this == TOP || this == BOTTOM; + } + + public boolean positiveAxis() { + return this == RIGHT || this == BOTTOM; + } + + public int normalX() { + return switch (this) { + case LEFT -> -1; + case RIGHT -> 1; + case TOP, BOTTOM -> 0; + }; + } + + public int normalY() { + return switch (this) { + case TOP -> -1; + case BOTTOM -> 1; + case LEFT, RIGHT -> 0; + }; + } + + public PortSide opposite() { + return switch (this) { + case LEFT -> RIGHT; + case RIGHT -> LEFT; + case TOP -> BOTTOM; + case BOTTOM -> TOP; + }; + } + } + + public record PortWidget(NodeId nodeId, PortDefinition definition, PortSide side, int centerX, int centerY, int radius, int hitRadius) { + public boolean contains(double mouseX, double mouseY) { + return mouseX >= centerX - hitRadius && mouseX <= centerX + hitRadius && mouseY >= centerY - hitRadius && mouseY <= centerY + hitRadius; + } + + public boolean isInput() { + return definition.direction() == PortDirection.INPUT; + } + } + + public sealed interface RowWidget permits InputPortRowWidget, OutputPortRowWidget, TextControlRowWidget, NumericTextControlRowWidget, BooleanControlRowWidget, CycleControlRowWidget { + int y(); + + int height(); + } + + public record InputPortRowWidget(int y, int height, PortWidget port, Bounds fieldBounds, boolean connected) implements RowWidget { + } + + public record OutputPortRowWidget(int y, int height, PortWidget port) implements RowWidget { + } + + public record TextControlRowWidget(int y, int height, NodeEditorControl.TextControl control, Bounds fieldBounds, boolean labelVisible) implements RowWidget { + } + + public record NumericTextControlRowWidget(int y, int height, NodeEditorControl.NumericTextControl control, Bounds fieldBounds) implements RowWidget { + } + + public record BooleanControlRowWidget(int y, int height, NodeEditorControl.BooleanControl control, Bounds toggleBounds) implements RowWidget { + } + + public record CycleControlRowWidget( + int y, + int height, + NodeEditorControl.CycleControl control, + Bounds leftArrowBounds, + Bounds valueBounds, + Bounds rightArrowBounds + ) implements RowWidget { + } + + public record Bounds(int x, int y, int width, int height) { + public boolean contains(double mouseX, double mouseY) { + return mouseX >= x && mouseX <= x + width && mouseY >= y && mouseY <= y + height; + } + } + + public record ResizeHandle(ResizeDirection direction) { + } + + public sealed interface InlineHit permits InlineHit.PortTextFieldHit, InlineHit.PortBooleanHit, InlineHit.ControlTextFieldHit, InlineHit.ControlBooleanHit, InlineHit.ControlCycleHit { + record PortTextFieldHit(NodeId nodeId, PortId portId, Bounds bounds, boolean numeric) implements InlineHit { + } + + record PortBooleanHit(NodeId nodeId, PortId portId) implements InlineHit { + } + + record ControlTextFieldHit(NodeId nodeId, String key, Bounds bounds, boolean numeric) implements InlineHit { + } + + record ControlBooleanHit(NodeId nodeId, String key) implements InlineHit { + } + + record ControlCycleHit(NodeId nodeId, String key, int direction) implements InlineHit { + } + } + + private record RequestedNodeSize(int width, int height) { + } + + private record FullLayout(List ports, List rows, List channelSeparators, Bounds bodyBounds, int height, int minHeight) { + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeWidgetRenderer.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeWidgetRenderer.java new file mode 100644 index 0000000..aa774e9 --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeWidgetRenderer.java @@ -0,0 +1,286 @@ +package com.github.squi2rel.mcng.fabric.client; + +import com.github.squi2rel.mcng.core.NodeConfigValues; +import com.github.squi2rel.mcng.core.NodeEditorControl; +import com.github.squi2rel.mcng.core.NodeId; +import com.github.squi2rel.mcng.core.NodeVisualStyle; +import com.github.squi2rel.mcng.core.NumericTypes; +import com.github.squi2rel.mcng.core.PortId; +import java.util.function.BiPredicate; +import java.util.function.Function; +import java.util.function.Predicate; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphicsExtractor; + +final class NodeWidgetRenderer { + private NodeWidgetRenderer() { + } + + static void render( + GuiGraphicsExtractor context, + Font textRenderer, + NodeWidget widget, + GraphEditorUiConfig uiConfig, + GraphEditorTheme theme, + boolean selected, + boolean executing, + boolean hasError, + Function portColorResolver, + Predicate pendingPort, + BiPredicate activePortEditor, + BiPredicate activeControlEditor + ) { + NodeRenderDetailLevel detailLevel = widget.detailLevel(); + int bodyColor = executing + ? EditorStyleRenderer.blend(theme.nodeBodyColor(), theme.executionColor(), 0.12f) + : selected ? EditorStyleRenderer.blend(theme.nodeBodyColor(), theme.accentColor(), 0.18f) : theme.nodeBodyColor(); + int headerColor = executing + ? EditorStyleRenderer.blend(theme.nodeHeaderColor(), theme.executionColor(), 0.18f) + : selected ? EditorStyleRenderer.blend(theme.nodeHeaderColor(), theme.accentColor(), 0.22f) : theme.nodeHeaderColor(); + int borderColor = executing ? theme.executionColor() : hasError ? theme.errorColor() : selected ? theme.accentColor() : theme.nodeBorderColor(); + boolean compactReroute = widget.nodeType().visualStyle() == NodeVisualStyle.COMPACT_REROUTE; + + if (compactReroute) { + EditorStyleRenderer.drawBox(context, widget.x(), widget.y(), widget.width(), widget.height(), bodyColor, borderColor, uiConfig); + } else { + EditorStyleRenderer.drawNodeBox(context, widget, bodyColor, headerColor, borderColor, uiConfig); + } + + if (!compactReroute && widget.showHeaderTitle() && detailLevel != NodeRenderDetailLevel.MINIMAL) { + int titleY = widget.y() + Math.max(2, (widget.headerHeight() - textRenderer.lineHeight) / 2); + context.text(textRenderer, GraphEditorTranslations.nodeTitle(widget.i18n(), widget.nodeType()), widget.x() + widget.edgePadding(), titleY, theme.primaryTextColor(), false); + } + + if (!compactReroute && detailLevel == NodeRenderDetailLevel.FULL) { + for (NodeWidget.RowWidget row : widget.rows()) { + renderRow(context, textRenderer, widget, row, uiConfig, theme, activePortEditor, activeControlEditor); + } + renderBody(context, textRenderer, widget, theme, uiConfig, selected, executing, hasError); + } + + if (!compactReroute) { + int separatorColor = EditorStyleRenderer.blend(theme.nodeBorderColor(), theme.primaryTextColor(), 0.12f); + for (int separatorY : widget.channelSeparators()) { + context.fill(widget.x() + 1, separatorY, widget.x() + widget.width() - 1, separatorY + 1, separatorColor); + } + } + + for (NodeWidget.PortWidget port : widget.ports()) { + int color = portColorResolver.apply(port); + if (pendingPort.test(port)) { + color = EditorStyleRenderer.brighten(color, 0.22f); + } + EditorStyleRenderer.drawPort(context, port.centerX(), port.centerY(), port.radius(), color, uiConfig.portShape()); + } + } + + private static void renderRow( + GuiGraphicsExtractor context, + Font textRenderer, + NodeWidget widget, + NodeWidget.RowWidget row, + GraphEditorUiConfig uiConfig, + GraphEditorTheme theme, + BiPredicate activePortEditor, + BiPredicate activeControlEditor + ) { + switch (row) { + case NodeWidget.InputPortRowWidget inputRow -> renderInputRow(context, textRenderer, widget, inputRow, uiConfig, theme, activePortEditor); + case NodeWidget.OutputPortRowWidget outputRow -> renderOutputRow(context, textRenderer, widget, outputRow, theme); + case NodeWidget.TextControlRowWidget textControlRow -> renderTextControlRow(context, textRenderer, widget, textControlRow, uiConfig, theme, activeControlEditor); + case NodeWidget.NumericTextControlRowWidget numericTextControlRow -> renderNumericTextControlRow(context, textRenderer, widget, numericTextControlRow, uiConfig, theme, activeControlEditor); + case NodeWidget.BooleanControlRowWidget booleanControlRow -> renderBooleanControlRow(context, textRenderer, widget, booleanControlRow, uiConfig, theme); + case NodeWidget.CycleControlRowWidget cycleControlRow -> renderCycleControlRow(context, textRenderer, widget, cycleControlRow, uiConfig, theme); + } + } + + private static void renderInputRow( + GuiGraphicsExtractor context, + Font textRenderer, + NodeWidget widget, + NodeWidget.InputPortRowWidget row, + GraphEditorUiConfig uiConfig, + GraphEditorTheme theme, + BiPredicate activePortEditor + ) { + String label = trimText(textRenderer, GraphEditorTranslations.portLabel(widget.i18n(), widget.nodeType(), row.port().definition()), labelWidthForInputRow(widget, row)); + int labelX = row.port().centerX() + row.port().radius() + 6; + int labelY = row.y() + Math.max(2, (row.height() - textRenderer.lineHeight) / 2); + context.text(textRenderer, label, labelX, labelY, theme.primaryTextColor(), false); + if (row.fieldBounds() == null || activePortEditor.test(row.port().nodeId(), row.port().definition().id())) { + return; + } + + int fill = row.connected() ? EditorStyleRenderer.darken(theme.nodeBodyColor(), 0.08f) : EditorStyleRenderer.darken(theme.nodeBodyColor(), 0.04f); + int border = row.connected() ? theme.nodeBorderColor() : theme.panelBorderColor(); + EditorStyleRenderer.drawBox(context, row.fieldBounds().x(), row.fieldBounds().y(), row.fieldBounds().width(), row.fieldBounds().height(), fill, border, uiConfig); + Object value; + try { + value = NodeConfigValues.readInlineInputValue(widget.node().config(), row.port().definition()); + } catch (IllegalArgumentException exception) { + value = GraphEditorTranslations.ui(widget.i18n(), "common.invalid", ""); + } + String renderedValue = switch (row.port().definition().inlineWidget().kind()) { + case NUMERIC_TEXT -> NumericTypes.displayText(value); + case STRING_TEXT -> String.valueOf(value); + case BOOLEAN_TOGGLE -> Boolean.TRUE.equals(value) + ? GraphEditorTranslations.ui(widget.i18n(), "common.on", "On") + : GraphEditorTranslations.ui(widget.i18n(), "common.off", "Off"); + }; + context.text(textRenderer, trimText(textRenderer, renderedValue, row.fieldBounds().width() - 8), row.fieldBounds().x() + 4, row.fieldBounds().y() + 3, theme.secondaryTextColor(), false); + } + + private static void renderOutputRow(GuiGraphicsExtractor context, Font textRenderer, NodeWidget widget, NodeWidget.OutputPortRowWidget row, GraphEditorTheme theme) { + String label = trimText(textRenderer, GraphEditorTranslations.portLabel(widget.i18n(), widget.nodeType(), row.port().definition()), outputLabelWidth(widget, row)); + int labelWidth = textRenderer.width(label); + int labelRight = row.port().centerX() - row.port().radius() - 6; + int labelX = Math.max(widget.x() + widget.edgePadding(), labelRight - labelWidth); + int labelY = row.y() + Math.max(2, (row.height() - textRenderer.lineHeight) / 2); + context.text(textRenderer, label, labelX, labelY, theme.primaryTextColor(), false); + } + + private static void renderTextControlRow( + GuiGraphicsExtractor context, + Font textRenderer, + NodeWidget widget, + NodeWidget.TextControlRowWidget row, + GraphEditorUiConfig uiConfig, + GraphEditorTheme theme, + BiPredicate activeControlEditor + ) { + if (row.labelVisible()) { + context.text(textRenderer, trimText(textRenderer, GraphEditorTranslations.controlLabel(widget.i18n(), widget.nodeType(), row.control()), labelWidthForControlRow(widget, row.fieldBounds())), widget.x() + widget.edgePadding(), row.y() + Math.max(2, (row.height() - textRenderer.lineHeight) / 2), theme.primaryTextColor(), false); + } + if (activeControlEditor.test(widget.node().id(), row.control().key())) { + return; + } + int fill = row.labelVisible() + ? EditorStyleRenderer.darken(theme.nodeBodyColor(), 0.04f) + : EditorStyleRenderer.darken(theme.nodeHeaderColor(), 0.04f); + EditorStyleRenderer.drawBox(context, row.fieldBounds().x(), row.fieldBounds().y(), row.fieldBounds().width(), row.fieldBounds().height(), fill, theme.panelBorderColor(), uiConfig); + String value = NodeConfigValues.readTextControlValue(widget.node().config(), row.control()); + context.text(textRenderer, trimText(textRenderer, value, row.fieldBounds().width() - 8), row.fieldBounds().x() + 4, row.fieldBounds().y() + 3, theme.secondaryTextColor(), false); + } + + private static void renderNumericTextControlRow( + GuiGraphicsExtractor context, + Font textRenderer, + NodeWidget widget, + NodeWidget.NumericTextControlRowWidget row, + GraphEditorUiConfig uiConfig, + GraphEditorTheme theme, + BiPredicate activeControlEditor + ) { + context.text(textRenderer, trimText(textRenderer, GraphEditorTranslations.controlLabel(widget.i18n(), widget.nodeType(), row.control()), labelWidthForControlRow(widget, row.fieldBounds())), widget.x() + widget.edgePadding(), row.y() + Math.max(2, (row.height() - textRenderer.lineHeight) / 2), theme.primaryTextColor(), false); + if (activeControlEditor.test(widget.node().id(), row.control().key())) { + return; + } + EditorStyleRenderer.drawBox(context, row.fieldBounds().x(), row.fieldBounds().y(), row.fieldBounds().width(), row.fieldBounds().height(), EditorStyleRenderer.darken(theme.nodeBodyColor(), 0.04f), theme.panelBorderColor(), uiConfig); + String value = NodeConfigValues.readNumericTextControlValue(widget.node().config(), row.control()); + context.text(textRenderer, trimText(textRenderer, value, row.fieldBounds().width() - 8), row.fieldBounds().x() + 4, row.fieldBounds().y() + 3, theme.secondaryTextColor(), false); + } + + private static void renderBooleanControlRow( + GuiGraphicsExtractor context, + Font textRenderer, + NodeWidget widget, + NodeWidget.BooleanControlRowWidget row, + GraphEditorUiConfig uiConfig, + GraphEditorTheme theme + ) { + context.text(textRenderer, trimText(textRenderer, GraphEditorTranslations.controlLabel(widget.i18n(), widget.nodeType(), row.control()), labelWidthForControlRow(widget, row.toggleBounds())), widget.x() + widget.edgePadding(), row.y() + Math.max(2, (row.height() - textRenderer.lineHeight) / 2), theme.primaryTextColor(), false); + boolean value = NodeConfigValues.readBooleanControlValue(widget.node().config(), row.control()); + int fill = value ? EditorStyleRenderer.blend(theme.nodeBodyColor(), theme.accentColor(), 0.2f) : EditorStyleRenderer.darken(theme.nodeBodyColor(), 0.04f); + int border = value ? theme.accentColor() : theme.panelBorderColor(); + EditorStyleRenderer.drawBox(context, row.toggleBounds().x(), row.toggleBounds().y(), row.toggleBounds().width(), row.toggleBounds().height(), fill, border, uiConfig); + String label = value + ? GraphEditorTranslations.ui(widget.i18n(), "common.enabled", "Enabled") + : GraphEditorTranslations.ui(widget.i18n(), "common.disabled", "Disabled"); + context.text(textRenderer, trimText(textRenderer, label, row.toggleBounds().width() - 8), row.toggleBounds().x() + 4, row.toggleBounds().y() + 3, theme.secondaryTextColor(), false); + } + + private static void renderCycleControlRow( + GuiGraphicsExtractor context, + Font textRenderer, + NodeWidget widget, + NodeWidget.CycleControlRowWidget row, + GraphEditorUiConfig uiConfig, + GraphEditorTheme theme + ) { + context.text(textRenderer, trimText(textRenderer, GraphEditorTranslations.controlLabel(widget.i18n(), widget.nodeType(), row.control()), labelWidthForControlRow(widget, row.valueBounds())), widget.x() + widget.edgePadding(), row.y() + Math.max(2, (row.height() - textRenderer.lineHeight) / 2), theme.primaryTextColor(), false); + EditorStyleRenderer.drawBox(context, row.leftArrowBounds().x(), row.leftArrowBounds().y(), row.leftArrowBounds().width(), row.leftArrowBounds().height(), EditorStyleRenderer.darken(theme.nodeBodyColor(), 0.04f), theme.panelBorderColor(), uiConfig); + EditorStyleRenderer.drawBox(context, row.valueBounds().x(), row.valueBounds().y(), row.valueBounds().width(), row.valueBounds().height(), EditorStyleRenderer.darken(theme.nodeBodyColor(), 0.04f), theme.panelBorderColor(), uiConfig); + EditorStyleRenderer.drawBox(context, row.rightArrowBounds().x(), row.rightArrowBounds().y(), row.rightArrowBounds().width(), row.rightArrowBounds().height(), EditorStyleRenderer.darken(theme.nodeBodyColor(), 0.04f), theme.panelBorderColor(), uiConfig); + context.text(textRenderer, "<", row.leftArrowBounds().x() + 4, row.leftArrowBounds().y() + 3, theme.secondaryTextColor(), false); + context.text(textRenderer, ">", row.rightArrowBounds().x() + 4, row.rightArrowBounds().y() + 3, theme.secondaryTextColor(), false); + String currentId = NodeConfigValues.readCycleControlValue(widget.node().config(), row.control()); + String currentLabel = row.control().options().stream() + .filter(option -> option.id().equals(currentId)) + .map(option -> GraphEditorTranslations.controlOptionLabel(widget.i18n(), widget.nodeType(), row.control(), option)) + .findFirst() + .orElse(currentId); + context.text(textRenderer, trimText(textRenderer, currentLabel, row.valueBounds().width() - 8), row.valueBounds().x() + 4, row.valueBounds().y() + 3, theme.secondaryTextColor(), false); + } + + private static void renderBody( + GuiGraphicsExtractor context, + Font textRenderer, + NodeWidget widget, + GraphEditorTheme theme, + GraphEditorUiConfig uiConfig, + boolean selected, + boolean executing, + boolean hasError + ) { + if (!widget.hasVisibleBody() || widget.bodyComponent() == null) { + return; + } + widget.bodyComponent().render(new NodeBodyRenderContext( + context, + textRenderer, + widget.bodyBounds(), + widget.node(), + widget.nodeType(), + widget.session(), + widget.i18n(), + uiConfig, + theme, + widget.zoom(), + selected, + executing, + hasError, + widget.preview() + )); + } + + private static int labelWidthForInputRow(NodeWidget widget, NodeWidget.InputPortRowWidget row) { + int labelX = row.port().centerX() + row.port().radius() + 6; + int right = row.fieldBounds() == null ? widget.x() + widget.width() - widget.edgePadding() : row.fieldBounds().x() - 6; + return Math.max(18, right - labelX); + } + + private static int outputLabelWidth(NodeWidget widget, NodeWidget.OutputPortRowWidget row) { + return Math.max(18, (row.port().centerX() - row.port().radius() - 6) - (widget.x() + widget.edgePadding())); + } + + private static int labelWidthForControlRow(NodeWidget widget, NodeWidget.Bounds controlBounds) { + return Math.max(18, controlBounds.x() - (widget.x() + widget.edgePadding()) - 6); + } + + private static String trimText(Font textRenderer, String value, int maxWidth) { + if (textRenderer.width(value) <= maxWidth) { + return value; + } + String ellipsis = "..."; + int ellipsisWidth = textRenderer.width(ellipsis); + if (ellipsisWidth >= maxWidth) { + return ""; + } + String candidate = value; + while (!candidate.isEmpty() && textRenderer.width(candidate) + ellipsisWidth > maxWidth) { + candidate = candidate.substring(0, candidate.length() - 1); + } + return candidate + ellipsis; + } + +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/PortShape.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/PortShape.java new file mode 100644 index 0000000..17186e2 --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/PortShape.java @@ -0,0 +1,6 @@ +package com.github.squi2rel.mcng.fabric.client; + +public enum PortShape { + CIRCLE, + SQUARE +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/RerouteOrientation.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/RerouteOrientation.java new file mode 100644 index 0000000..4d74286 --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/RerouteOrientation.java @@ -0,0 +1,60 @@ +package com.github.squi2rel.mcng.fabric.client; + +import com.github.squi2rel.mcng.core.PortDirection; + +public enum RerouteOrientation { + LEFT_TO_RIGHT("left_to_right", NodeWidget.PortSide.LEFT), + RIGHT_TO_LEFT("right_to_left", NodeWidget.PortSide.RIGHT), + TOP_TO_BOTTOM("top_to_bottom", NodeWidget.PortSide.TOP), + BOTTOM_TO_TOP("bottom_to_top", NodeWidget.PortSide.BOTTOM); + + private final String id; + private final NodeWidget.PortSide inputSide; + + RerouteOrientation(String id, NodeWidget.PortSide inputSide) { + this.id = id; + this.inputSide = inputSide; + } + + public String id() { + return id; + } + + public NodeWidget.PortSide inputSide() { + return inputSide; + } + + public NodeWidget.PortSide outputSide() { + return inputSide.opposite(); + } + + public boolean vertical() { + return inputSide.isVertical(); + } + + public static RerouteOrientation fromInputSide(NodeWidget.PortSide inputSide) { + return switch (inputSide) { + case LEFT -> LEFT_TO_RIGHT; + case RIGHT -> RIGHT_TO_LEFT; + case TOP -> TOP_TO_BOTTOM; + case BOTTOM -> BOTTOM_TO_TOP; + }; + } + + public static RerouteOrientation fromPortSide(NodeWidget.PortSide side, PortDirection direction) { + return fromInputSide(direction == PortDirection.INPUT ? side : side.opposite()); + } + + public static RerouteOrientation fromFixedPort(NodeWidget.PortSide side, PortDirection direction) { + return fromPortSide(side, direction); + } + + public static RerouteOrientation parse(String value) { + for (RerouteOrientation orientation : values()) { + if (orientation.id.equals(value)) { + return orientation; + } + } + return null; + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/ResizeDirection.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/ResizeDirection.java new file mode 100644 index 0000000..17033ee --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/ResizeDirection.java @@ -0,0 +1,32 @@ +package com.github.squi2rel.mcng.fabric.client; + +public enum ResizeDirection { + LEFT, + RIGHT, + TOP, + BOTTOM, + TOP_LEFT, + TOP_RIGHT, + BOTTOM_LEFT, + BOTTOM_RIGHT; + + public boolean includesLeft() { + return this == LEFT || this == TOP_LEFT || this == BOTTOM_LEFT; + } + + public boolean includesRight() { + return this == RIGHT || this == TOP_RIGHT || this == BOTTOM_RIGHT; + } + + public boolean includesTop() { + return this == TOP || this == TOP_LEFT || this == TOP_RIGHT; + } + + public boolean includesBottom() { + return this == BOTTOM || this == BOTTOM_LEFT || this == BOTTOM_RIGHT; + } + + public boolean isCorner() { + return (includesLeft() || includesRight()) && (includesTop() || includesBottom()); + } +} diff --git a/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/ResizePolicy.java b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/ResizePolicy.java new file mode 100644 index 0000000..19986ce --- /dev/null +++ b/mcng-fabric-client-26.2/src/main/java/com/github/squi2rel/mcng/fabric/client/ResizePolicy.java @@ -0,0 +1,35 @@ +package com.github.squi2rel.mcng.fabric.client; + +public record ResizePolicy( + boolean allowLeft, + boolean allowRight, + boolean allowTop, + boolean allowBottom +) { + public static ResizePolicy none() { + return new ResizePolicy(false, false, false, false); + } + + public static ResizePolicy horizontal() { + return new ResizePolicy(true, true, false, false); + } + + public static ResizePolicy vertical() { + return new ResizePolicy(false, false, true, true); + } + + public static ResizePolicy allSides() { + return new ResizePolicy(true, true, true, true); + } + + public boolean resizable() { + return allowLeft || allowRight || allowTop || allowBottom; + } + + public boolean supports(ResizeDirection direction) { + return (!direction.includesLeft() || allowLeft) + && (!direction.includesRight() || allowRight) + && (!direction.includesTop() || allowTop) + && (!direction.includesBottom() || allowBottom); + } +} diff --git a/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/EdgeRendererTest.java b/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/EdgeRendererTest.java new file mode 100644 index 0000000..b49d186 --- /dev/null +++ b/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/EdgeRendererTest.java @@ -0,0 +1,81 @@ +package com.github.squi2rel.mcng.fabric.client; + +import com.github.squi2rel.mcng.core.MCNGPortTypes; +import com.github.squi2rel.mcng.core.NodeId; +import com.github.squi2rel.mcng.core.PortDefinition; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class EdgeRendererTest { + @Test + void curveControlsRespectActualPortSides() { + EdgeRenderer.CurveControls controls = EdgeRenderer.curveControls( + 100, + 60, + 200, + 90, + NodeWidget.PortSide.RIGHT, + NodeWidget.PortSide.RIGHT + ); + + assertTrue(controls.control1X() > 100.0); + assertTrue(controls.control2X() > 200.0); + } + + @Test + void curveControlsRespectVerticalPortSides() { + EdgeRenderer.CurveControls controls = EdgeRenderer.curveControls( + 120, + 140, + 130, + 40, + NodeWidget.PortSide.TOP, + NodeWidget.PortSide.BOTTOM + ); + + assertTrue(controls.control1Y() < 140.0); + assertTrue(controls.control2Y() > 40.0); + } + + @Test + void pendingEdgeFromInputUsesMouseAsLogicalSource() { + NodeWidget.PortWidget inputPort = new NodeWidget.PortWidget( + new NodeId("reroute"), + PortDefinition.input("value", "Value", MCNGPortTypes.ANY, false), + NodeWidget.PortSide.RIGHT, + 200, + 100, + 4, + 6 + ); + + GraphCanvasComponent.PendingEdgeGeometry geometry = GraphCanvasComponent.pendingEdgeGeometry(inputPort, 80, 120); + + assertEquals(80, geometry.startX()); + assertEquals(120, geometry.startY()); + assertEquals(200, geometry.endX()); + assertEquals(100, geometry.endY()); + assertEquals(NodeWidget.PortSide.RIGHT, geometry.startSide()); + assertEquals(NodeWidget.PortSide.RIGHT, geometry.endSide()); + } + + @Test + void pendingEdgeCanUseVerticalFreeEndpointSide() { + NodeWidget.PortWidget inputPort = new NodeWidget.PortWidget( + new NodeId("reroute"), + PortDefinition.input("value", "Value", MCNGPortTypes.ANY, false), + NodeWidget.PortSide.BOTTOM, + 120, + 180, + 4, + 6 + ); + + GraphCanvasComponent.PendingEdgeGeometry geometry = GraphCanvasComponent.pendingEdgeGeometry(inputPort, 118, 20); + + assertEquals(NodeWidget.PortSide.BOTTOM, geometry.startSide()); + assertEquals(NodeWidget.PortSide.BOTTOM, geometry.endSide()); + } +} diff --git a/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/GraphCursorManagerTest.java b/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/GraphCursorManagerTest.java new file mode 100644 index 0000000..3f34895 --- /dev/null +++ b/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/GraphCursorManagerTest.java @@ -0,0 +1,20 @@ +package com.github.squi2rel.mcng.fabric.client; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class GraphCursorManagerTest { + @Test + void mapsResizeDirectionsToExpectedCursorKinds() { + assertEquals(GraphCursorManager.CursorKind.DEFAULT, GraphCursorManager.forResizeDirection(null)); + assertEquals(GraphCursorManager.CursorKind.RESIZE_EW, GraphCursorManager.forResizeDirection(ResizeDirection.LEFT)); + assertEquals(GraphCursorManager.CursorKind.RESIZE_EW, GraphCursorManager.forResizeDirection(ResizeDirection.RIGHT)); + assertEquals(GraphCursorManager.CursorKind.RESIZE_NS, GraphCursorManager.forResizeDirection(ResizeDirection.TOP)); + assertEquals(GraphCursorManager.CursorKind.RESIZE_NS, GraphCursorManager.forResizeDirection(ResizeDirection.BOTTOM)); + assertEquals(GraphCursorManager.CursorKind.RESIZE_NWSE, GraphCursorManager.forResizeDirection(ResizeDirection.TOP_LEFT)); + assertEquals(GraphCursorManager.CursorKind.RESIZE_NWSE, GraphCursorManager.forResizeDirection(ResizeDirection.BOTTOM_RIGHT)); + assertEquals(GraphCursorManager.CursorKind.RESIZE_NESW, GraphCursorManager.forResizeDirection(ResizeDirection.TOP_RIGHT)); + assertEquals(GraphCursorManager.CursorKind.RESIZE_NESW, GraphCursorManager.forResizeDirection(ResizeDirection.BOTTOM_LEFT)); + } +} diff --git a/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/GraphEditorComponentPlacementTest.java b/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/GraphEditorComponentPlacementTest.java new file mode 100644 index 0000000..7ffa51a --- /dev/null +++ b/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/GraphEditorComponentPlacementTest.java @@ -0,0 +1,63 @@ +package com.github.squi2rel.mcng.fabric.client; + +import com.github.squi2rel.mcng.core.NodePosition; +import com.github.squi2rel.mcng.core.NodeType; +import com.github.squi2rel.mcng.core.builtin.BuiltinNodeTypes; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + + +class GraphEditorComponentPlacementTest { + @Test + void placementPositionMatchesPreviewForRegularNode() { + assertPlacementMatchesPreview(BuiltinNodeTypes.ADD); + } + + @Test + void placementPositionMatchesPreviewForReroute() { + assertPlacementMatchesPreview(BuiltinNodeTypes.REROUTE); + } + + @Test + void placementPositionMatchesPreviewForRegisteredBodyComponent() { + GraphViewportState viewport = new GraphViewportState(); + viewport.reset(); + GraphEditorBounds canvasBounds = new GraphEditorBounds(20, 48, 640, 360); + double screenX = 240.0; + double screenY = 180.0; + NodeComponentRegistry components = new NodeComponentRegistry() + .register(new NodeComponentDefinition(BuiltinNodeTypes.ADD.id(), PlacementPreviewBody::new, ResizePolicy.allSides())); + + NodeWidget preview = NodeWidget.preview(BuiltinNodeTypes.ADD, GraphEditorUiConfig.defaultConfig(), new PlacementPreviewBody(), ResizePolicy.allSides(), (int) Math.round(screenX), (int) Math.round(screenY)); + NodePosition placement = GraphEditorComponent.placementPosition(viewport, canvasBounds, BuiltinNodeTypes.ADD, GraphEditorUiConfig.defaultConfig(), components, screenX, screenY); + + double placedScreenX = canvasBounds.x() + viewport.toScreenX(placement.x()); + double placedScreenY = canvasBounds.y() + viewport.toScreenY(placement.y()); + assertEquals(preview.x(), (int) Math.round(placedScreenX)); + assertEquals(preview.y(), (int) Math.round(placedScreenY)); + } + + private static void assertPlacementMatchesPreview(NodeType nodeType) { + GraphViewportState viewport = new GraphViewportState(); + viewport.reset(); + GraphEditorBounds canvasBounds = new GraphEditorBounds(20, 48, 640, 360); + double screenX = 240.0; + double screenY = 180.0; + + NodeWidget preview = NodeWidget.preview(nodeType, (int) Math.round(screenX), (int) Math.round(screenY)); + NodePosition placement = GraphEditorComponent.placementPosition(viewport, canvasBounds, nodeType, screenX, screenY); + + double placedScreenX = canvasBounds.x() + viewport.toScreenX(placement.x()); + double placedScreenY = canvasBounds.y() + viewport.toScreenY(placement.y()); + assertEquals(preview.x(), (int) Math.round(placedScreenX)); + assertEquals(preview.y(), (int) Math.round(placedScreenY)); + } + + private static final class PlacementPreviewBody implements NodeBodyComponent { + @Override + public NodeBodyMeasurement measure(NodeBodyMeasureContext context) { + return new NodeBodyMeasurement(true, 120, 72, 160, 72); + } + } +} diff --git a/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/GraphEditorComponentTest.java b/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/GraphEditorComponentTest.java new file mode 100644 index 0000000..3f43e8e --- /dev/null +++ b/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/GraphEditorComponentTest.java @@ -0,0 +1,262 @@ +package com.github.squi2rel.mcng.fabric.client; + +import com.github.squi2rel.mcng.core.GraphBuilder; +import com.github.squi2rel.mcng.core.GraphDefinition; +import com.github.squi2rel.mcng.core.GraphDocument; +import com.github.squi2rel.mcng.core.GraphJsonCodec; +import com.github.squi2rel.mcng.core.GraphLayout; +import com.github.squi2rel.mcng.core.MCNGPortTypes; +import com.github.squi2rel.mcng.core.NodeId; +import com.github.squi2rel.mcng.core.NodePosition; +import com.github.squi2rel.mcng.core.NodeTypeRegistry; +import com.github.squi2rel.mcng.core.PortDirection; +import com.github.squi2rel.mcng.core.PortId; +import com.github.squi2rel.mcng.core.PortTypeRegistry; +import com.github.squi2rel.mcng.core.builtin.BuiltinNodeTypes; +import com.github.squi2rel.mcng.core.builtin.BuiltinNodeRegistrar; +import org.junit.jupiter.api.Test; +import org.lwjgl.glfw.GLFW; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +class GraphEditorComponentTest { + @Test + void resizingEditorDoesNotResetPaletteScrollOffset() throws ReflectiveOperationException { + GraphEditorComponent editor = createEditor(); + editor.init(null, new GraphEditorBounds(0, 0, 800, 480)); + + NodePaletteComponent palette = palette(editor); + palette.toggle(); + palette.mouseScrolled(40, 120, 0.0, -1.0); + assertEquals(18.0, scrollOffset(palette)); + + editor.setBounds(new GraphEditorBounds(0, 0, 960, 540)); + assertEquals(18.0, scrollOffset(palette)); + } + + @Test + void rightClickCancelsPendingConnectionWithoutOpeningMenu() throws ReflectiveOperationException { + GraphBuilder builder = new GraphBuilder(registry(), portTypes()); + NodeId source = builder.addNode(BuiltinNodeTypes.NUMERIC_CONSTANT, new BuiltinNodeTypes.NumericConstantConfig("4.0"), new NodePosition(20, 20)); + builder.addNode(BuiltinNodeTypes.DEBUG_OUTPUT, BuiltinNodeTypes.EmptyConfig.INSTANCE, new NodePosition(220, 20)); + + GraphEditorComponent editor = createEditor(builder.buildDocument()); + editor.init(null, new GraphEditorBounds(0, 0, 800, 480)); + editor.session().toggleConnectionCandidate(source, BuiltinNodeTypes.VALUE_PORT, PortDirection.OUTPUT); + + assertTrue(editor.mouseClicked(200, 200, 1)); + assertNull(editor.session().pendingConnection()); + assertFalse(contextMenu(editor).isOpen()); + + editor.mouseReleased(200, 200, 1); + assertFalse(contextMenu(editor).isOpen()); + } + + @Test + void rightClickingOutputPortClearsOutgoingEdges() throws ReflectiveOperationException { + GraphBuilder builder = new GraphBuilder(registry(), portTypes()); + NodeId source = builder.addNode(BuiltinNodeTypes.NUMERIC_CONSTANT, new BuiltinNodeTypes.NumericConstantConfig("4.0"), new NodePosition(20, 20)); + NodeId add = builder.addNode(BuiltinNodeTypes.ADD, BuiltinNodeTypes.EmptyConfig.INSTANCE, new NodePosition(220, 20)); + NodeId debug = builder.addNode(BuiltinNodeTypes.DEBUG_OUTPUT, BuiltinNodeTypes.EmptyConfig.INSTANCE, new NodePosition(220, 140)); + builder.addEdge(source, "value", add, "left"); + builder.addEdge(source, "value", debug, "value"); + + GraphEditorComponent editor = createEditor(builder.buildDocument()); + editor.init(null, new GraphEditorBounds(0, 0, 800, 480)); + double[] point = portScreenPosition(editor, source, BuiltinNodeTypes.VALUE_PORT, PortDirection.OUTPUT); + + assertTrue(editor.mouseClicked(point[0], point[1], 1)); + assertTrue(editor.session().edges().isEmpty()); + } + + @Test + void rightClickingInputPortClearsIncomingEdges() throws ReflectiveOperationException { + GraphBuilder builder = new GraphBuilder(registry(), portTypes()); + NodeId source = builder.addNode(BuiltinNodeTypes.NUMERIC_CONSTANT, new BuiltinNodeTypes.NumericConstantConfig("4.0"), new NodePosition(20, 20)); + NodeId add = builder.addNode(BuiltinNodeTypes.ADD, BuiltinNodeTypes.EmptyConfig.INSTANCE, new NodePosition(220, 20)); + builder.addEdge(source, "value", add, "left"); + + GraphEditorComponent editor = createEditor(builder.buildDocument()); + editor.init(null, new GraphEditorBounds(0, 0, 800, 480)); + double[] point = portScreenPosition(editor, add, BuiltinNodeTypes.LEFT_PORT, PortDirection.INPUT); + + assertTrue(editor.mouseClicked(point[0], point[1], 1)); + assertTrue(editor.session().edges().isEmpty()); + } + + @Test + void clickingASelectedNodeCollapsesMultiSelectionToThatNode() { + GraphBuilder builder = new GraphBuilder(registry(), portTypes()); + NodeId left = builder.addNode(BuiltinNodeTypes.NUMERIC_CONSTANT, new BuiltinNodeTypes.NumericConstantConfig("1.0"), new NodePosition(20, 20)); + NodeId right = builder.addNode(BuiltinNodeTypes.NUMERIC_CONSTANT, new BuiltinNodeTypes.NumericConstantConfig("2.0"), new NodePosition(220, 20)); + + GraphEditorSession session = new GraphEditorSession( + registry(), + portTypes(), + new GraphJsonCodec(), + builder.buildDocument(), + new TestHost() + ); + GraphInteractionController controller = new GraphInteractionController(); + GraphCanvasComponent canvas = new GraphCanvasComponent(session, controller, GraphEditorUiConfig::defaultConfig); + canvas.init(null, new GraphEditorBounds(0, 0, 800, 480)); + session.selectNodes(List.of(left, right), false); + NodeWidget widget = new NodeWidget(session.node(left), session.nodeType(session.node(left)), session, new GraphLayout(session.positions()), canvas.viewport()); + double localX = canvas.viewport().toScreenX(widget.x() + (widget.width() / 2.0)); + double localY = canvas.viewport().toScreenY(widget.y() + (widget.height() / 2.0)); + + assertTrue(controller.mouseClicked(canvas, localX, localY, 0)); + assertTrue(controller.mouseReleased(canvas, localX, localY, 0)); + assertEquals(java.util.Set.of(left), session.selectedNodeIds()); + } + + @Test + void ctrlZUndoesLatestDocumentEdit() { + GraphEditorComponent editor = createEditor(); + editor.init(null, new GraphEditorBounds(0, 0, 800, 480)); + editor.session().addNode(BuiltinNodeTypes.NUMERIC_CONSTANT.id(), 40, 40); + + assertEquals(1, editor.session().nodes().size()); + assertTrue(editor.keyPressed(GLFW.GLFW_KEY_Z, 0, GLFW.GLFW_MOD_CONTROL)); + assertTrue(editor.session().nodes().isEmpty()); + } + + @Test + void ctrlShiftZRedoesLatestDocumentEdit() { + GraphEditorComponent editor = createEditor(); + editor.init(null, new GraphEditorBounds(0, 0, 800, 480)); + editor.session().addNode(BuiltinNodeTypes.NUMERIC_CONSTANT.id(), 40, 40); + assertTrue(editor.keyPressed(GLFW.GLFW_KEY_Z, 0, GLFW.GLFW_MOD_CONTROL)); + + assertTrue(editor.keyPressed(GLFW.GLFW_KEY_Z, 0, GLFW.GLFW_MOD_CONTROL | GLFW.GLFW_MOD_SHIFT)); + assertEquals(1, editor.session().nodes().size()); + } + + @Test + void focusedPaletteSearchConsumesUndoShortcutBeforeSessionHistory() throws ReflectiveOperationException { + GraphEditorComponent editor = createEditor(); + editor.init(null, new GraphEditorBounds(0, 0, 800, 480)); + editor.session().addNode(BuiltinNodeTypes.NUMERIC_CONSTANT.id(), 40, 40); + + NodePaletteComponent palette = palette(editor); + palette.toggle(); + searchField(palette).setFocused(true); + + assertTrue(editor.keyPressed(GLFW.GLFW_KEY_Z, 0, GLFW.GLFW_MOD_CONTROL)); + assertEquals(1, editor.session().nodes().size()); + assertTrue(editor.session().canUndo()); + } + + private static GraphEditorComponent createEditor() { + return createEditor(GraphDocument.of(new GraphDefinition(List.of(), List.of()), new GraphLayout(Map.of()))); + } + + private static GraphEditorComponent createEditor(GraphDocument document) { + NodeTypeRegistry registry = registry(); + PortTypeRegistry portTypes = portTypes(); + GraphEditorSession session = new GraphEditorSession( + registry, + portTypes, + new GraphJsonCodec(), + document, + new TestHost() + ); + NodePaletteRegistry paletteRegistry = new NodePaletteRegistry(); + BuiltinNodePaletteRegistrar.registerAll(paletteRegistry); + return new GraphEditorComponent(session, paletteRegistry, GraphEditorUiConfig.defaultConfig()); + } + + private static NodeTypeRegistry registry() { + NodeTypeRegistry registry = new NodeTypeRegistry(); + BuiltinNodeRegistrar.registerCoreNodes(registry); + BuiltinNodeRegistrar.registerDebugNodes(registry); + return registry; + } + + private static PortTypeRegistry portTypes() { + return MCNGPortTypes.createRegistry(); + } + + private static NodePaletteComponent palette(GraphEditorComponent editor) throws ReflectiveOperationException { + Field field = GraphEditorComponent.class.getDeclaredField("palette"); + field.setAccessible(true); + return (NodePaletteComponent) field.get(editor); + } + + private static GraphCanvasComponent canvas(GraphEditorComponent editor) throws ReflectiveOperationException { + Field field = GraphEditorComponent.class.getDeclaredField("canvas"); + field.setAccessible(true); + return (GraphCanvasComponent) field.get(editor); + } + + private static GraphContextMenuComponent contextMenu(GraphEditorComponent editor) throws ReflectiveOperationException { + Field field = GraphEditorComponent.class.getDeclaredField("contextMenu"); + field.setAccessible(true); + return (GraphContextMenuComponent) field.get(editor); + } + + private static double scrollOffset(NodePaletteComponent palette) throws ReflectiveOperationException { + Field stateField = NodePaletteComponent.class.getDeclaredField("state"); + stateField.setAccessible(true); + Object state = stateField.get(palette); + Field scrollOffsetField = state.getClass().getDeclaredField("scrollOffset"); + scrollOffsetField.setAccessible(true); + return scrollOffsetField.getDouble(state); + } + + private static GraphTextFieldComponent searchField(NodePaletteComponent palette) throws ReflectiveOperationException { + Field field = NodePaletteComponent.class.getDeclaredField("searchField"); + field.setAccessible(true); + return (GraphTextFieldComponent) field.get(palette); + } + + private static double[] portScreenPosition(GraphEditorComponent editor, NodeId nodeId, PortId portId, PortDirection direction) throws ReflectiveOperationException { + GraphCanvasComponent canvas = canvas(editor); + var session = editor.session(); + var node = session.node(nodeId); + NodeWidget widget = new NodeWidget(node, session.nodeType(node), session, new GraphLayout(session.positions()), canvas.viewport()); + var port = GraphCanvasComponent.findPortWidget(widget, portId, direction); + return new double[] { + canvas.bounds().x() + canvas.viewport().toScreenX(port.centerX()), + canvas.bounds().y() + canvas.viewport().toScreenY(port.centerY()) + }; + } + + private static double[] nodeScreenPosition(GraphEditorComponent editor, NodeId nodeId) throws ReflectiveOperationException { + GraphCanvasComponent canvas = canvas(editor); + var session = editor.session(); + var node = session.node(nodeId); + NodeWidget widget = new NodeWidget(node, session.nodeType(node), session, new GraphLayout(session.positions()), canvas.viewport()); + return new double[] { + canvas.bounds().x() + canvas.viewport().toScreenX(widget.x() + (widget.width() / 2.0)), + canvas.bounds().y() + canvas.viewport().toScreenY(widget.y() + (widget.height() / 2.0)) + }; + } + + private static final class TestHost implements GraphEditorHost { + @Override + public void onDocumentChanged(GraphDocument document) { + } + + @Override + public void copyToClipboard(String value) { + } + + @Override + public String readClipboard() { + return ""; + } + + @Override + public void showMessage(String message) { + } + } +} diff --git a/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/GraphEditorRegistryIsolationTest.java b/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/GraphEditorRegistryIsolationTest.java new file mode 100644 index 0000000..8678856 --- /dev/null +++ b/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/GraphEditorRegistryIsolationTest.java @@ -0,0 +1,58 @@ +package com.github.squi2rel.mcng.fabric.client; + +import com.github.squi2rel.mcng.core.GraphDocument; +import com.github.squi2rel.mcng.core.GraphJsonCodec; +import com.github.squi2rel.mcng.core.GraphLayout; +import com.github.squi2rel.mcng.core.GraphDefinition; +import com.github.squi2rel.mcng.core.MCNGPortTypes; +import com.github.squi2rel.mcng.core.NodeTypeRegistry; +import com.github.squi2rel.mcng.core.PortTypeRegistry; +import com.github.squi2rel.mcng.core.builtin.BuiltinNodeRegistrar; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GraphEditorRegistryIsolationTest { + @Test + void sessionsCanUseDifferentRuntimeRegistries() { + NodeTypeRegistry coreOnly = new NodeTypeRegistry(); + BuiltinNodeRegistrar.registerCoreNodes(coreOnly); + NodeTypeRegistry corePlusDebug = new NodeTypeRegistry(); + BuiltinNodeRegistrar.registerCoreNodes(corePlusDebug); + BuiltinNodeRegistrar.registerDebugNodes(corePlusDebug); + PortTypeRegistry portTypes = MCNGPortTypes.createRegistry(); + + GraphEditorSession left = new GraphEditorSession(coreOnly, portTypes, new GraphJsonCodec(), emptyDocument(), new TestHost()); + GraphEditorSession right = new GraphEditorSession(corePlusDebug, portTypes, new GraphJsonCodec(), emptyDocument(), new TestHost()); + + assertFalse(left.availableNodeTypes().stream().anyMatch(nodeType -> nodeType.id().equals(com.github.squi2rel.mcng.core.builtin.BuiltinNodeTypes.DEBUG_OUTPUT.id()))); + assertTrue(right.availableNodeTypes().stream().anyMatch(nodeType -> nodeType.id().equals(com.github.squi2rel.mcng.core.builtin.BuiltinNodeTypes.DEBUG_OUTPUT.id()))); + } + + private static GraphDocument emptyDocument() { + return GraphDocument.of(new GraphDefinition(List.of(), List.of()), new GraphLayout(Map.of())); + } + + private static final class TestHost implements GraphEditorHost { + @Override + public void onDocumentChanged(GraphDocument document) { + } + + @Override + public void copyToClipboard(String value) { + } + + @Override + public String readClipboard() { + return ""; + } + + @Override + public void showMessage(String message) { + } + } +} diff --git a/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/GraphEditorSessionTest.java b/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/GraphEditorSessionTest.java new file mode 100644 index 0000000..44e7151 --- /dev/null +++ b/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/GraphEditorSessionTest.java @@ -0,0 +1,760 @@ +package com.github.squi2rel.mcng.fabric.client; + +import com.github.squi2rel.mcng.core.DocumentNodeDefinition; +import com.github.squi2rel.mcng.core.DocumentNodeDefinitionKind; +import com.github.squi2rel.mcng.core.DocumentNodeTypes; +import com.github.squi2rel.mcng.core.EdgeDefinition; +import com.github.squi2rel.mcng.core.GraphBuilder; +import com.github.squi2rel.mcng.core.GraphDefinition; +import com.github.squi2rel.mcng.core.GraphDocument; +import com.github.squi2rel.mcng.core.GraphJsonCodec; +import com.github.squi2rel.mcng.core.GraphLayout; +import com.github.squi2rel.mcng.core.GraphScope; +import com.github.squi2rel.mcng.core.MCNGPortTypes; +import com.github.squi2rel.mcng.core.NodeId; +import com.github.squi2rel.mcng.core.NodeInstance; +import com.github.squi2rel.mcng.core.NodePosition; +import com.github.squi2rel.mcng.core.NodeTypeRegistry; +import com.github.squi2rel.mcng.core.PortChannel; +import com.github.squi2rel.mcng.core.PortDirection; +import com.github.squi2rel.mcng.core.PortId; +import com.github.squi2rel.mcng.core.PortTypeRegistry; +import com.github.squi2rel.mcng.core.SubgraphDefinition; +import com.github.squi2rel.mcng.core.builtin.BuiltinNodeTypes; +import com.github.squi2rel.mcng.core.builtin.BuiltinNodeTypes.ManualTriggerConfig; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GraphEditorSessionTest { + @Test + void subgraphEditorPropagatesParentTypeIntoInnerGenericChain() { + GraphEditorSession session = new GraphEditorSession( + registry(), + portTypes(), + new GraphJsonCodec(), + document(DocumentNodeDefinitionKind.SUBGRAPH, "subgraph"), + new TestHost() + ); + + assertTrue(session.enterDefinition(new NodeId("subgraphNode"))); + assertEquals(MCNGPortTypes.DOUBLE, session.effectivePortType(new NodeId("in"), new PortId("value"), PortDirection.OUTPUT)); + assertEquals(MCNGPortTypes.DOUBLE, session.effectivePortType(new NodeId("identity"), new PortId("value"), PortDirection.INPUT)); + assertEquals(MCNGPortTypes.DOUBLE, session.effectivePortType(new NodeId("identity"), new PortId("value"), PortDirection.OUTPUT)); + assertEquals(MCNGPortTypes.DOUBLE, session.effectivePortType(new NodeId("out"), new PortId("value"), PortDirection.INPUT)); + } + + @Test + void customNodeEditorDoesNotPullTypeBackFromParentInstance() { + GraphEditorSession session = new GraphEditorSession( + registry(), + portTypes(), + new GraphJsonCodec(), + document(DocumentNodeDefinitionKind.CUSTOM_NODE, "custom"), + new TestHost() + ); + + assertTrue(session.enterDefinition(new NodeId("customNode"))); + assertEquals(MCNGPortTypes.ANY, session.effectivePortType(new NodeId("in"), new PortId("value"), PortDirection.OUTPUT)); + assertEquals(MCNGPortTypes.ANY, session.effectivePortType(new NodeId("identity"), new PortId("value"), PortDirection.INPUT)); + } + + @Test + void copyAndPasteSelectionUsesLocalClipboard() { + GraphEditorSession session = new GraphEditorSession( + registry(), + portTypes(), + new GraphJsonCodec(), + rootDocument(), + new TestHost() + ); + + session.selectNodes(List.of(new NodeId("left"), new NodeId("right")), false); + assertTrue(session.copySelectionToLocalClipboard()); + assertTrue(session.hasLocalClipboard()); + assertTrue(session.pasteLocalClipboard(300, 180)); + + assertEquals(5, session.nodes().size()); + assertEquals(2, session.selectedNodeIds().size()); + assertTrue(session.selectedNodeIds().stream().allMatch(nodeId -> !nodeId.equals(new NodeId("left")) && !nodeId.equals(new NodeId("right")))); + } + + @Test + void removingSelectedNodesDoesNotAutoSelectAnotherNode() { + GraphEditorSession session = new GraphEditorSession( + registry(), + portTypes(), + new GraphJsonCodec(), + rootDocument(), + new TestHost() + ); + + session.selectNode(new NodeId("left")); + session.removeSelectedNodes(); + + assertTrue(session.selectedNodeIds().isEmpty()); + assertTrue(session.selectedNodeId().isEmpty()); + assertNull(session.pendingConnection()); + assertEquals(2, session.nodes().size()); + assertEquals(1, session.edges().size()); + } + + @Test + void clearingOutputPortRemovesAllOutgoingEdges() { + GraphBuilder builder = new GraphBuilder(registry(), portTypes()); + NodeId source = builder.addNode(BuiltinNodeTypes.NUMERIC_CONSTANT, new BuiltinNodeTypes.NumericConstantConfig("4.0"), new NodePosition(20, 20)); + NodeId add = builder.addNode(BuiltinNodeTypes.ADD, BuiltinNodeTypes.EmptyConfig.INSTANCE, new NodePosition(220, 20)); + NodeId debug = builder.addNode(BuiltinNodeTypes.DEBUG_OUTPUT, BuiltinNodeTypes.EmptyConfig.INSTANCE, new NodePosition(220, 140)); + builder.addEdge(source, "value", add, "left"); + builder.addEdge(source, "value", debug, "value"); + + GraphEditorSession session = new GraphEditorSession( + registry(), + portTypes(), + new GraphJsonCodec(), + builder.buildDocument(), + new TestHost() + ); + + session.clearPortConnections(source, BuiltinNodeTypes.VALUE_PORT, PortDirection.OUTPUT); + + assertTrue(session.edges().isEmpty()); + } + + @Test + void clearingInputPortRemovesIncomingEdges() { + GraphBuilder builder = new GraphBuilder(registry(), portTypes()); + NodeId source = builder.addNode(BuiltinNodeTypes.NUMERIC_CONSTANT, new BuiltinNodeTypes.NumericConstantConfig("4.0"), new NodePosition(20, 20)); + NodeId add = builder.addNode(BuiltinNodeTypes.ADD, BuiltinNodeTypes.EmptyConfig.INSTANCE, new NodePosition(220, 20)); + builder.addEdge(source, "value", add, "left"); + + GraphEditorSession session = new GraphEditorSession( + registry(), + portTypes(), + new GraphJsonCodec(), + builder.buildDocument(), + new TestHost() + ); + + session.clearPortConnections(add, BuiltinNodeTypes.LEFT_PORT, PortDirection.INPUT); + + assertTrue(session.edges().isEmpty()); + } + + @Test + void freshAddNodeInfersNumericTypesFromInlineDefaults() { + GraphBuilder builder = new GraphBuilder(registry(), portTypes()); + NodeId add = builder.addNode(BuiltinNodeTypes.ADD, BuiltinNodeTypes.EmptyConfig.INSTANCE, new NodePosition(220, 20)); + + GraphEditorSession session = new GraphEditorSession( + registry(), + portTypes(), + new GraphJsonCodec(), + builder.buildDocument(), + new TestHost() + ); + + assertEquals(MCNGPortTypes.DOUBLE, session.effectivePortType(add, BuiltinNodeTypes.LEFT_PORT, PortDirection.INPUT)); + assertEquals(MCNGPortTypes.DOUBLE, session.effectivePortType(add, BuiltinNodeTypes.RIGHT_PORT, PortDirection.INPUT)); + assertEquals(MCNGPortTypes.DOUBLE, session.effectivePortType(add, BuiltinNodeTypes.VALUE_PORT, PortDirection.OUTPUT)); + } + + @Test + void definitionBoundaryNumericInlineDefaultsInferAsDouble() { + GraphEditorSession session = new GraphEditorSession( + registry(), + portTypes(), + new GraphJsonCodec(), + customInlineNumericDocument(false), + new TestHost() + ); + + assertEquals(MCNGPortTypes.DOUBLE, session.effectivePortType(new NodeId("customNode"), new PortId("in"), PortDirection.INPUT)); + } + + @Test + void definitionBoundaryConnectionTypeOverridesNumericInlineInference() { + GraphEditorSession session = new GraphEditorSession( + registry(), + portTypes(), + new GraphJsonCodec(), + customInlineNumericDocument(true), + new TestHost() + ); + + assertEquals(MCNGPortTypes.LONG, session.effectivePortType(new NodeId("customNode"), new PortId("in"), PortDirection.INPUT)); + } + + @Test + void pastedSubgraphNodeGetsNewDefinitionId() { + GraphEditorSession session = new GraphEditorSession( + registry(), + portTypes(), + new GraphJsonCodec(), + document(DocumentNodeDefinitionKind.SUBGRAPH, "subgraph"), + new TestHost() + ); + + session.selectNode(new NodeId("subgraphNode")); + assertTrue(session.copySelectionToLocalClipboard()); + assertTrue(session.pasteLocalClipboard(260, 140)); + + assertEquals(2, session.document().subgraphs().size()); + NodeInstance original = session.node(new NodeId("subgraphNode")); + NodeInstance pasted = session.selectedNodeIds().stream() + .map(session::node) + .filter(node -> !node.id().equals(new NodeId("subgraphNode"))) + .findFirst() + .orElseThrow(); + assertTrue(DocumentNodeTypes.isSubgraphType(pasted.typeId())); + assertFalse(pasted.typeId().equals(original.typeId())); + } + + @Test + void pastedCustomNodeKeepsOriginalDefinitionId() { + GraphEditorSession session = new GraphEditorSession( + registry(), + portTypes(), + new GraphJsonCodec(), + document(DocumentNodeDefinitionKind.CUSTOM_NODE, "custom"), + new TestHost() + ); + + session.selectNode(new NodeId("customNode")); + assertTrue(session.copySelectionToLocalClipboard()); + assertTrue(session.pasteLocalClipboard(260, 140)); + + assertEquals(1, session.definitions().size()); + NodeInstance original = session.node(new NodeId("customNode")); + NodeInstance pasted = session.selectedNodeIds().stream() + .map(session::node) + .filter(node -> !node.id().equals(new NodeId("customNode"))) + .findFirst() + .orElseThrow(); + assertEquals(original.typeId(), pasted.typeId()); + } + + @Test + void helperNodesCannotBePastedIntoRootGraph() { + GraphEditorSession session = new GraphEditorSession( + registry(), + portTypes(), + new GraphJsonCodec(), + document(DocumentNodeDefinitionKind.SUBGRAPH, "subgraph"), + new TestHost() + ); + + assertTrue(session.enterDefinition(new NodeId("subgraphNode"))); + session.selectNode(new NodeId("in")); + assertTrue(session.copySelectionToLocalClipboard()); + assertTrue(session.exitToBreadcrumb(null)); + assertFalse(session.pasteLocalClipboard(40, 40)); + } + + @Test + void autoCastConnectedToGenericNodeDoesNotOverflowDuringTypeResolution() { + NodeTypeRegistry registry = registry(); + PortTypeRegistry portTypes = portTypes(); + GraphDefinition graph = new GraphDefinition( + List.of( + new NodeInstance(new NodeId("source"), BuiltinNodeTypes.NUMERIC_CONSTANT.id(), BuiltinNodeTypes.NUMERIC_CONSTANT.configCodec().toJson(new BuiltinNodeTypes.NumericConstantConfig("4.0"))), + new NodeInstance(new NodeId("cast"), BuiltinNodeTypes.CAST.id(), BuiltinNodeTypes.CAST.configCodec().toJson(new BuiltinNodeTypes.CastConfig("auto"))), + new NodeInstance(new NodeId("debug"), BuiltinNodeTypes.DEBUG_OUTPUT.id(), BuiltinNodeTypes.EmptyConfig.CODEC.toJson(BuiltinNodeTypes.EmptyConfig.INSTANCE)) + ), + List.of( + new EdgeDefinition(new NodeId("source"), new PortId("value"), new NodeId("cast"), new PortId("value")), + new EdgeDefinition(new NodeId("cast"), new PortId("value"), new NodeId("debug"), new PortId("value")) + ) + ); + GraphDocument document = GraphDocument.of(graph, new GraphLayout(Map.of())); + GraphEditorSession session = new GraphEditorSession( + registry, + portTypes, + new GraphJsonCodec(), + document, + new TestHost() + ); + + assertEquals(MCNGPortTypes.DOUBLE, session.effectivePortType(new NodeId("cast"), new PortId("value"), PortDirection.OUTPUT)); + assertEquals(MCNGPortTypes.DOUBLE, session.effectivePortType(new NodeId("debug"), new PortId("value"), PortDirection.INPUT)); + assertEquals(MCNGPortTypes.DOUBLE, session.effectivePortType(new NodeId("debug"), new PortId("value"), PortDirection.OUTPUT)); + } + + @Test + void reroutePortsResolveAsControlWhenConnectedToControlFlow() { + GraphBuilder builder = new GraphBuilder(registry(), portTypes()); + NodeId trigger = builder.addNode(BuiltinNodeTypes.MANUAL_TRIGGER, new ManualTriggerConfig("pulse", false, "plain"), new NodePosition(20, 20)); + NodeId reroute = builder.addNode(BuiltinNodeTypes.REROUTE, BuiltinNodeTypes.EmptyConfig.INSTANCE, new NodePosition(220, 20)); + NodeId debug = builder.addNode(BuiltinNodeTypes.DEBUG_OUTPUT, BuiltinNodeTypes.EmptyConfig.INSTANCE, new NodePosition(420, 20)); + builder.addEdge(trigger, "out", reroute, "value"); + builder.addEdge(reroute, "value", debug, "in"); + builder.addEdge(trigger, "message", debug, "value"); + + GraphEditorSession session = new GraphEditorSession( + registry(), + portTypes(), + new GraphJsonCodec(), + builder.buildDocument(), + new TestHost() + ); + + assertEquals(PortChannel.CONTROL, session.effectivePortChannel(reroute, BuiltinNodeTypes.VALUE_PORT, PortDirection.INPUT)); + assertEquals(PortChannel.CONTROL, session.effectivePortChannel(reroute, BuiltinNodeTypes.VALUE_PORT, PortDirection.OUTPUT)); + assertEquals(MCNGPortTypes.ANY, session.effectivePortType(reroute, BuiltinNodeTypes.VALUE_PORT, PortDirection.INPUT)); + } + + @Test + void rerouteChainResolvesAsControlAcrossMultipleNodes() { + GraphBuilder builder = new GraphBuilder(registry(), portTypes()); + NodeId trigger = builder.addNode(BuiltinNodeTypes.MANUAL_TRIGGER, new ManualTriggerConfig("pulse", false, "plain"), new NodePosition(20, 20)); + NodeId rerouteA = builder.addNode(BuiltinNodeTypes.REROUTE, BuiltinNodeTypes.EmptyConfig.INSTANCE, new NodePosition(220, 20)); + NodeId rerouteB = builder.addNode(BuiltinNodeTypes.REROUTE, BuiltinNodeTypes.EmptyConfig.INSTANCE, new NodePosition(320, 20)); + NodeId debug = builder.addNode(BuiltinNodeTypes.DEBUG_OUTPUT, BuiltinNodeTypes.EmptyConfig.INSTANCE, new NodePosition(520, 20)); + builder.addEdge(trigger, "out", rerouteA, "value"); + builder.addEdge(rerouteA, "value", rerouteB, "value"); + builder.addEdge(rerouteB, "value", debug, "in"); + builder.addEdge(trigger, "message", debug, "value"); + + GraphEditorSession session = new GraphEditorSession( + registry(), + portTypes(), + new GraphJsonCodec(), + builder.buildDocument(), + new TestHost() + ); + + assertEquals(PortChannel.CONTROL, session.effectivePortChannel(rerouteA, BuiltinNodeTypes.VALUE_PORT, PortDirection.INPUT)); + assertEquals(PortChannel.CONTROL, session.effectivePortChannel(rerouteA, BuiltinNodeTypes.VALUE_PORT, PortDirection.OUTPUT)); + assertEquals(PortChannel.CONTROL, session.effectivePortChannel(rerouteB, BuiltinNodeTypes.VALUE_PORT, PortDirection.INPUT)); + assertEquals(PortChannel.CONTROL, session.effectivePortChannel(rerouteB, BuiltinNodeTypes.VALUE_PORT, PortDirection.OUTPUT)); + } + + @Test + void editorRejectsMixingDataIntoControlRerouteChain() { + GraphBuilder builder = new GraphBuilder(registry(), portTypes()); + NodeId trigger = builder.addNode(BuiltinNodeTypes.MANUAL_TRIGGER, new ManualTriggerConfig("pulse", false, "plain"), new NodePosition(20, 20)); + NodeId reroute = builder.addNode(BuiltinNodeTypes.REROUTE, BuiltinNodeTypes.EmptyConfig.INSTANCE, new NodePosition(220, 20)); + NodeId debug = builder.addNode(BuiltinNodeTypes.DEBUG_OUTPUT, BuiltinNodeTypes.EmptyConfig.INSTANCE, new NodePosition(420, 20)); + builder.addEdge(trigger, "out", reroute, "value"); + + GraphEditorSession session = new GraphEditorSession( + registry(), + portTypes(), + new GraphJsonCodec(), + builder.buildDocument(), + new TestHost() + ); + + assertTrue(session.toggleConnectionCandidate(reroute, BuiltinNodeTypes.VALUE_PORT, PortDirection.OUTPUT)); + assertFalse(session.toggleConnectionCandidate(debug, BuiltinNodeTypes.VALUE_PORT, PortDirection.INPUT)); + assertEquals(1, session.edges().size()); + } + + @Test + void editorCanReverseRerouteChainWhenAnchoredLater() { + GraphBuilder builder = new GraphBuilder(registry(), portTypes()); + NodeId source = builder.addNode(BuiltinNodeTypes.NUMERIC_CONSTANT, new BuiltinNodeTypes.NumericConstantConfig("3"), new NodePosition(20, 20)); + NodeId rerouteA = builder.addNode(BuiltinNodeTypes.REROUTE, BuiltinNodeTypes.EmptyConfig.INSTANCE, new NodePosition(220, 20)); + NodeId rerouteB = builder.addNode(BuiltinNodeTypes.REROUTE, BuiltinNodeTypes.EmptyConfig.INSTANCE, new NodePosition(320, 20)); + NodeId debug = builder.addNode(BuiltinNodeTypes.DEBUG_OUTPUT, BuiltinNodeTypes.EmptyConfig.INSTANCE, new NodePosition(520, 20)); + + GraphEditorSession session = new GraphEditorSession( + registry(), + portTypes(), + new GraphJsonCodec(), + builder.buildDocument(), + new TestHost() + ); + + assertTrue(session.toggleConnectionCandidate(rerouteA, BuiltinNodeTypes.VALUE_PORT, PortDirection.OUTPUT)); + assertTrue(session.toggleConnectionCandidate(rerouteB, BuiltinNodeTypes.VALUE_PORT, PortDirection.INPUT)); + assertTrue(session.edges().stream().anyMatch(edge -> + edge.fromNodeId().equals(rerouteA) + && edge.toNodeId().equals(rerouteB) + && edge.fromPortId().equals(BuiltinNodeTypes.VALUE_PORT) + && edge.toPortId().equals(BuiltinNodeTypes.VALUE_PORT) + )); + + assertTrue(session.toggleConnectionCandidate(source, BuiltinNodeTypes.VALUE_PORT, PortDirection.OUTPUT)); + assertTrue(session.toggleConnectionCandidate(rerouteB, BuiltinNodeTypes.VALUE_PORT, PortDirection.INPUT)); + assertEquals(2, session.edges().size()); + assertTrue(session.edges().stream().anyMatch(edge -> + edge.fromNodeId().equals(source) + && edge.toNodeId().equals(rerouteB) + && edge.fromPortId().equals(BuiltinNodeTypes.VALUE_PORT) + && edge.toPortId().equals(BuiltinNodeTypes.VALUE_PORT) + )); + assertTrue(session.edges().stream().anyMatch(edge -> + edge.fromNodeId().equals(rerouteB) + && edge.toNodeId().equals(rerouteA) + && edge.fromPortId().equals(BuiltinNodeTypes.VALUE_PORT) + && edge.toPortId().equals(BuiltinNodeTypes.VALUE_PORT) + )); + + assertTrue(session.toggleConnectionCandidate(rerouteA, BuiltinNodeTypes.VALUE_PORT, PortDirection.OUTPUT)); + assertTrue(session.toggleConnectionCandidate(debug, BuiltinNodeTypes.VALUE_PORT, PortDirection.INPUT)); + assertEquals(3, session.edges().size()); + assertTrue(session.edges().stream().anyMatch(edge -> + edge.fromNodeId().equals(rerouteA) + && edge.toNodeId().equals(debug) + && edge.fromPortId().equals(BuiltinNodeTypes.VALUE_PORT) + && edge.toPortId().equals(BuiltinNodeTypes.VALUE_PORT) + )); + assertEquals(MCNGPortTypes.INT, session.effectivePortType(rerouteA, BuiltinNodeTypes.VALUE_PORT, PortDirection.INPUT)); + assertEquals(MCNGPortTypes.INT, session.effectivePortType(rerouteB, BuiltinNodeTypes.VALUE_PORT, PortDirection.OUTPUT)); + } + + @Test + void editorCanFlipRerouteToAcceptInputOnRightSide() { + GraphBuilder builder = new GraphBuilder(registry(), portTypes()); + NodeId source = builder.addNode(BuiltinNodeTypes.NUMERIC_CONSTANT, new BuiltinNodeTypes.NumericConstantConfig("3.0"), new NodePosition(20, 20)); + NodeId reroute = builder.addNode(BuiltinNodeTypes.REROUTE, BuiltinNodeTypes.EmptyConfig.INSTANCE, new NodePosition(220, 20)); + + GraphEditorSession session = new GraphEditorSession( + registry(), + portTypes(), + new GraphJsonCodec(), + builder.buildDocument(), + new TestHost() + ); + + assertTrue(session.toggleConnectionCandidate(source, BuiltinNodeTypes.VALUE_PORT, PortDirection.OUTPUT)); + assertTrue(session.toggleConnectionCandidate(reroute, BuiltinNodeTypes.VALUE_PORT, PortDirection.OUTPUT, true)); + assertEquals(1, session.edges().size()); + assertEquals(RerouteOrientation.RIGHT_TO_LEFT, session.rerouteOrientation(reroute)); + } + + @Test + void editorCanFlipVerticalRerouteToAcceptInputOnBottomSide() { + GraphBuilder builder = new GraphBuilder(registry(), portTypes()); + NodeId source = builder.addNode(BuiltinNodeTypes.NUMERIC_CONSTANT, new BuiltinNodeTypes.NumericConstantConfig("3.0"), new NodePosition(20, 220)); + NodeId reroute = builder.addNode(BuiltinNodeTypes.REROUTE, BuiltinNodeTypes.EmptyConfig.INSTANCE, new NodePosition(220, 80)); + + GraphEditorSession session = new GraphEditorSession( + registry(), + portTypes(), + new GraphJsonCodec(), + builder.buildDocument(), + new TestHost() + ); + + session.resizeReroute(reroute, new NodePosition(220, 80), new com.github.squi2rel.mcng.core.NodeSize(16, 92), RerouteOrientation.TOP_TO_BOTTOM); + assertEquals(RerouteOrientation.TOP_TO_BOTTOM, session.rerouteOrientation(reroute)); + assertTrue(session.toggleConnectionCandidate(source, BuiltinNodeTypes.VALUE_PORT, PortDirection.OUTPUT)); + assertTrue(session.toggleConnectionCandidate(reroute, BuiltinNodeTypes.VALUE_PORT, PortDirection.OUTPUT, NodeWidget.PortSide.BOTTOM)); + assertEquals(RerouteOrientation.BOTTOM_TO_TOP, session.rerouteOrientation(reroute)); + } + + @Test + void shrinkingDynamicPortsRemovesInvalidEdges() { + GraphBuilder builder = new GraphBuilder(registry(), portTypes()); + NodeId trigger = builder.addNode(BuiltinNodeTypes.MANUAL_TRIGGER, new ManualTriggerConfig("pulse", false, "plain"), new NodePosition(20, 20)); + NodeId sequence = builder.addNode(BuiltinNodeTypes.SEQUENCE, new BuiltinNodeTypes.SequenceConfig(4), new NodePosition(220, 20)); + NodeId debug = builder.addNode(BuiltinNodeTypes.DEBUG_OUTPUT, BuiltinNodeTypes.EmptyConfig.INSTANCE, new NodePosition(420, 20)); + builder.addEdge(trigger, "out", sequence, "in"); + builder.addEdge(sequence, "out4", debug, "in"); + builder.addEdge(trigger, "message", debug, "value"); + + GraphEditorSession session = new GraphEditorSession( + registry(), + portTypes(), + new GraphJsonCodec(), + builder.buildDocument(), + new TestHost() + ); + + NodeInstance sequenceNode = session.node(sequence); + assertEquals(4, session.nodeType(sequenceNode).outputs(sequenceNode).size()); + assertTrue(session.edges().stream().anyMatch(edge -> edge.fromNodeId().equals(sequence) && edge.fromPortId().equals(new PortId("out4")))); + + session.updateControlValue(sequence, "outputCount", new JsonPrimitive("2")); + + sequenceNode = session.node(sequence); + assertEquals(2, session.nodeType(sequenceNode).outputs(sequenceNode).size()); + assertFalse(session.edges().stream().anyMatch(edge -> edge.fromNodeId().equals(sequence) && edge.fromPortId().equals(new PortId("out4")))); + } + + @Test + void executingNodeHighlightsConnectedRerouteChain() { + GraphBuilder builder = new GraphBuilder(registry(), portTypes()); + NodeId trigger = builder.addNode(BuiltinNodeTypes.MANUAL_TRIGGER, new ManualTriggerConfig("pulse", false, "plain"), new NodePosition(20, 20)); + NodeId rerouteA = builder.addNode(BuiltinNodeTypes.REROUTE, BuiltinNodeTypes.EmptyConfig.INSTANCE, new NodePosition(220, 20)); + NodeId rerouteB = builder.addNode(BuiltinNodeTypes.REROUTE, BuiltinNodeTypes.EmptyConfig.INSTANCE, new NodePosition(320, 20)); + NodeId debug = builder.addNode(BuiltinNodeTypes.DEBUG_OUTPUT, BuiltinNodeTypes.EmptyConfig.INSTANCE, new NodePosition(520, 20)); + builder.addEdge(trigger, "out", rerouteA, "value"); + builder.addEdge(rerouteA, "value", rerouteB, "value"); + builder.addEdge(rerouteB, "value", debug, "in"); + builder.addEdge(trigger, "message", debug, "value"); + + GraphEditorSession session = new GraphEditorSession( + registry(), + portTypes(), + new GraphJsonCodec(), + builder.buildDocument(), + new TestHost() + ); + + session.triggerSelectedEvent(); + session.tickExecution(1); + + assertTrue(session.isExecuting(trigger)); + assertTrue(session.isExecuting(rerouteA)); + assertTrue(session.isExecuting(rerouteB)); + } + + @Test + void compositeMoveProducesSingleUndoStep() { + GraphEditorSession session = new GraphEditorSession( + registry(), + portTypes(), + new GraphJsonCodec(), + rootDocument(), + new TestHost() + ); + + NodeId left = new NodeId("left"); + NodePosition original = session.positions().get(left); + session.beginCompositeEdit(); + session.moveNodes(Map.of(left, new NodePosition(80, 40))); + session.moveNodes(Map.of(left, new NodePosition(140, 60))); + session.endCompositeEdit(); + + assertEquals(new NodePosition(140, 60), session.positions().get(left)); + assertTrue(session.canUndo()); + assertTrue(session.undo()); + assertEquals(original, session.positions().get(left)); + assertFalse(session.canUndo()); + assertTrue(session.canRedo()); + assertTrue(session.redo()); + assertEquals(new NodePosition(140, 60), session.positions().get(left)); + } + + @Test + void undoRestoresDefinitionContextAndSelection() { + GraphEditorSession session = new GraphEditorSession( + registry(), + portTypes(), + new GraphJsonCodec(), + document(DocumentNodeDefinitionKind.SUBGRAPH, "subgraph"), + new TestHost() + ); + + assertTrue(session.enterDefinition(new NodeId("subgraphNode"))); + session.addNode(BuiltinNodeTypes.STRING_CONSTANT.id(), 120, 140); + NodeId createdNodeId = session.selectedNodeId().orElseThrow(); + assertTrue(session.exitToBreadcrumb(null)); + assertFalse(session.isInsideDefinition()); + + assertTrue(session.undo()); + assertTrue(session.isInsideDefinition()); + assertEquals("subgraph", session.currentDefinitionId()); + assertEquals(new NodeId("in"), session.selectedNodeId().orElseThrow()); + assertFalse(session.nodes().stream().anyMatch(node -> node.id().equals(createdNodeId))); + + assertTrue(session.redo()); + assertTrue(session.isInsideDefinition()); + assertEquals("subgraph", session.currentDefinitionId()); + assertEquals(createdNodeId, session.selectedNodeId().orElseThrow()); + } + + @Test + void replaceDocumentCanBeUndoneAndRedone() { + GraphEditorSession session = new GraphEditorSession( + registry(), + portTypes(), + new GraphJsonCodec(), + rootDocument(), + new TestHost() + ); + + GraphDocument replacement = GraphDocument.of(new GraphDefinition(List.of(), List.of()), new GraphLayout(Map.of())); + assertTrue(session.replaceDocument(replacement)); + assertTrue(session.nodes().isEmpty()); + + assertTrue(session.undo()); + assertEquals(3, session.nodes().size()); + assertTrue(session.redo()); + assertTrue(session.nodes().isEmpty()); + } + + private GraphDocument document(DocumentNodeDefinitionKind kind, String definitionId) { + String inputTypeId = kind == DocumentNodeDefinitionKind.SUBGRAPH + ? DocumentNodeTypes.SUBGRAPH_INPUT_TYPE_ID + : DocumentNodeTypes.GRAPH_INPUT_TYPE_ID; + String outputTypeId = kind == DocumentNodeDefinitionKind.SUBGRAPH + ? DocumentNodeTypes.SUBGRAPH_OUTPUT_TYPE_ID + : DocumentNodeTypes.GRAPH_OUTPUT_TYPE_ID; + GraphScope scope = GraphScope.of( + new GraphDefinition( + List.of( + new NodeInstance(new NodeId("in"), inputTypeId, portConfig("In")), + new NodeInstance(new NodeId("identity"), BuiltinNodeTypes.IDENTITY.id(), BuiltinNodeTypes.EmptyConfig.CODEC.toJson(BuiltinNodeTypes.EmptyConfig.INSTANCE)), + new NodeInstance(new NodeId("out"), outputTypeId, portConfig("Out")) + ), + List.of( + new EdgeDefinition(new NodeId("in"), new PortId("value"), new NodeId("identity"), new PortId("value")), + new EdgeDefinition(new NodeId("identity"), new PortId("value"), new NodeId("out"), new PortId("value")) + ) + ), + new GraphLayout(Map.of( + new NodeId("in"), new NodePosition(20, 20), + new NodeId("identity"), new NodePosition(180, 20), + new NodeId("out"), new NodePosition(340, 20) + )) + ); + + NodeId rootNodeId = new NodeId(definitionId + "Node"); + GraphDefinition rootGraph = new GraphDefinition( + List.of( + new NodeInstance(new NodeId("source"), BuiltinNodeTypes.NUMERIC_CONSTANT.id(), BuiltinNodeTypes.NUMERIC_CONSTANT.configCodec().toJson(new BuiltinNodeTypes.NumericConstantConfig("4.0"))), + new NodeInstance(new NodeId("debug"), BuiltinNodeTypes.DEBUG_OUTPUT.id(), BuiltinNodeTypes.EmptyConfig.CODEC.toJson(BuiltinNodeTypes.EmptyConfig.INSTANCE)), + new NodeInstance( + rootNodeId, + kind == DocumentNodeDefinitionKind.SUBGRAPH + ? DocumentNodeTypes.subgraphTypeId(definitionId) + : DocumentNodeTypes.definitionTypeId(definitionId), + new JsonObject() + ) + ), + List.of( + new EdgeDefinition(new NodeId("source"), new PortId("value"), new NodeId("debug"), new PortId("value")), + new EdgeDefinition(new NodeId("debug"), new PortId("value"), rootNodeId, new PortId("in")) + ) + ); + GraphLayout rootLayout = new GraphLayout(Map.of( + new NodeId("source"), new NodePosition(20, 20), + new NodeId("debug"), new NodePosition(220, 20), + rootNodeId, new NodePosition(420, 20) + )); + if (kind == DocumentNodeDefinitionKind.SUBGRAPH) { + return GraphDocument.of(new GraphScope(rootGraph, rootLayout, List.of(), List.of( + new SubgraphDefinition(definitionId, "Definition", scope) + )), List.of()); + } + return GraphDocument.of(new GraphScope(rootGraph, rootLayout, List.of(), List.of()), List.of( + new DocumentNodeDefinition(definitionId, "Definition", scope) + )); + } + + private GraphDocument rootDocument() { + GraphDefinition rootGraph = new GraphDefinition( + List.of( + new NodeInstance(new NodeId("left"), BuiltinNodeTypes.NUMERIC_CONSTANT.id(), BuiltinNodeTypes.NUMERIC_CONSTANT.configCodec().toJson(new BuiltinNodeTypes.NumericConstantConfig("2.0"))), + new NodeInstance(new NodeId("right"), BuiltinNodeTypes.NUMERIC_CONSTANT.id(), BuiltinNodeTypes.NUMERIC_CONSTANT.configCodec().toJson(new BuiltinNodeTypes.NumericConstantConfig("3.0"))), + new NodeInstance(new NodeId("add"), BuiltinNodeTypes.ADD.id(), BuiltinNodeTypes.EmptyConfig.CODEC.toJson(BuiltinNodeTypes.EmptyConfig.INSTANCE)) + ), + List.of( + new EdgeDefinition(new NodeId("left"), new PortId("value"), new NodeId("add"), BuiltinNodeTypes.LEFT_PORT), + new EdgeDefinition(new NodeId("right"), new PortId("value"), new NodeId("add"), BuiltinNodeTypes.RIGHT_PORT) + ) + ); + return GraphDocument.of(rootGraph, new GraphLayout(Map.of( + new NodeId("left"), new NodePosition(20, 20), + new NodeId("right"), new NodePosition(20, 120), + new NodeId("add"), new NodePosition(220, 70) + ))); + } + + private GraphDocument customInlineNumericDocument(boolean connectLongSource) { + GraphDefinition definitionGraph = new GraphDefinition( + List.of( + new NodeInstance(new NodeId("in"), DocumentNodeTypes.GRAPH_INPUT_TYPE_ID, portConfig("Input", true)), + new NodeInstance(new NodeId("add"), BuiltinNodeTypes.ADD.id(), BuiltinNodeTypes.EmptyConfig.CODEC.toJson(BuiltinNodeTypes.EmptyConfig.INSTANCE)), + new NodeInstance(new NodeId("out"), DocumentNodeTypes.GRAPH_OUTPUT_TYPE_ID, portConfig("Output")) + ), + List.of( + new EdgeDefinition(new NodeId("in"), new PortId("value"), new NodeId("add"), BuiltinNodeTypes.LEFT_PORT), + new EdgeDefinition(new NodeId("add"), BuiltinNodeTypes.VALUE_PORT, new NodeId("out"), new PortId("value")) + ) + ); + GraphLayout definitionLayout = new GraphLayout(Map.of( + new NodeId("in"), new NodePosition(20, 20), + new NodeId("add"), new NodePosition(180, 20), + new NodeId("out"), new NodePosition(360, 20) + )); + + GraphDefinition rootGraph; + GraphLayout rootLayout; + if (connectLongSource) { + rootGraph = new GraphDefinition( + List.of( + new NodeInstance(new NodeId("source"), BuiltinNodeTypes.NUMERIC_CONSTANT.id(), BuiltinNodeTypes.NUMERIC_CONSTANT.configCodec().toJson(new BuiltinNodeTypes.NumericConstantConfig("2147483648"))), + new NodeInstance(new NodeId("customNode"), DocumentNodeTypes.definitionTypeId("custom_inline"), new JsonObject()) + ), + List.of( + new EdgeDefinition(new NodeId("source"), new PortId("value"), new NodeId("customNode"), new PortId("in")) + ) + ); + rootLayout = new GraphLayout(Map.of( + new NodeId("source"), new NodePosition(20, 20), + new NodeId("customNode"), new NodePosition(220, 20) + )); + } else { + rootGraph = new GraphDefinition( + List.of( + new NodeInstance(new NodeId("customNode"), DocumentNodeTypes.definitionTypeId("custom_inline"), new JsonObject()) + ), + List.of() + ); + rootLayout = new GraphLayout(Map.of( + new NodeId("customNode"), new NodePosition(220, 20) + )); + } + + return GraphDocument.of( + rootGraph, + rootLayout, + List.of(new DocumentNodeDefinition("custom_inline", "Custom Inline", GraphScope.of(definitionGraph, definitionLayout))) + ); + } + + private static JsonObject portConfig(String name) { + return portConfig(name, false); + } + + private static JsonObject portConfig(String name, boolean inlineInput) { + JsonObject json = new JsonObject(); + json.addProperty("name", name); + if (inlineInput) { + json.addProperty("inlineInput", true); + } + return json; + } + + private static NodeTypeRegistry registry() { + NodeTypeRegistry registry = new NodeTypeRegistry(); + BuiltinNodeTypes.registerAll(registry); + return registry; + } + + private static PortTypeRegistry portTypes() { + return MCNGPortTypes.createRegistry(); + } + + private static final class TestHost implements GraphEditorHost { + @Override + public void onDocumentChanged(GraphDocument document) { + } + + @Override + public void copyToClipboard(String value) { + } + + @Override + public String readClipboard() { + return ""; + } + + @Override + public void showMessage(String message) { + } + } +} diff --git a/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/GraphEditorUiConfigTest.java b/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/GraphEditorUiConfigTest.java new file mode 100644 index 0000000..4178920 --- /dev/null +++ b/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/GraphEditorUiConfigTest.java @@ -0,0 +1,31 @@ +package com.github.squi2rel.mcng.fabric.client; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class GraphEditorUiConfigTest { + @Test + void defaultConfigUsesClassicCurveRoundedCircle() { + GraphEditorUiConfig config = GraphEditorUiConfig.defaultConfig(); + + assertEquals(GraphEditorTheme.classic(), config.theme()); + assertEquals(EdgeStyle.CURVE, config.edgeStyle()); + assertEquals(NodeCornerStyle.ROUNDED, config.nodeCornerStyle()); + assertEquals(PortShape.CIRCLE, config.portShape()); + } + + @Test + void withMethodsReplaceOnlyRequestedFields() { + GraphEditorUiConfig config = GraphEditorUiConfig.defaultConfig() + .withEdgeStyle(EdgeStyle.STRAIGHT) + .withNodeCornerStyle(NodeCornerStyle.SQUARE) + .withPortShape(PortShape.SQUARE) + .withTheme(GraphEditorTheme.light()); + + assertEquals(GraphEditorTheme.light(), config.theme()); + assertEquals(EdgeStyle.STRAIGHT, config.edgeStyle()); + assertEquals(NodeCornerStyle.SQUARE, config.nodeCornerStyle()); + assertEquals(PortShape.SQUARE, config.portShape()); + } +} diff --git a/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/GraphTextInputStateTest.java b/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/GraphTextInputStateTest.java new file mode 100644 index 0000000..4042f4b --- /dev/null +++ b/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/GraphTextInputStateTest.java @@ -0,0 +1,35 @@ +package com.github.squi2rel.mcng.fabric.client; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GraphTextInputStateTest { + @Test + void undoRedoRestoresTextEdits() { + GraphTextInputState state = new GraphTextInputState("ab"); + + state.insert("c"); + assertEquals("abc", state.text()); + assertTrue(state.canUndo()); + + assertTrue(state.undo()); + assertEquals("ab", state.text()); + assertTrue(state.canRedo()); + + assertTrue(state.redo()); + assertEquals("abc", state.text()); + } + + @Test + void cursorMovementDoesNotCreateUndoEntries() { + GraphTextInputState state = new GraphTextInputState("abc"); + + state.moveLeft(false, false); + state.moveLeft(false, true); + + assertFalse(state.canUndo()); + } +} diff --git a/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/MCNGDebugScreenLayoutTest.java b/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/MCNGDebugScreenLayoutTest.java new file mode 100644 index 0000000..cbee141 --- /dev/null +++ b/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/MCNGDebugScreenLayoutTest.java @@ -0,0 +1,132 @@ +package com.github.squi2rel.mcng.fabric.client; + +import com.github.squi2rel.mcng.core.GraphDefinition; +import com.github.squi2rel.mcng.core.GraphDocument; +import com.github.squi2rel.mcng.core.GraphJsonCodec; +import com.github.squi2rel.mcng.core.GraphLayout; +import com.github.squi2rel.mcng.core.MCNGPortTypes; +import com.github.squi2rel.mcng.core.NodeTypeRegistry; +import com.github.squi2rel.mcng.core.PortTypeRegistry; +import com.github.squi2rel.mcng.core.builtin.BuiltinNodeRegistrar; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import net.minecraft.client.gui.screens.Screen; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +class MCNGDebugScreenLayoutTest { + @Test + void debugPanelButtonsStayInsidePanelAndDoNotOverlapVariables() throws ReflectiveOperationException { + MCNGDebugScreen screen = createScreen(); + setScreenSize(screen, 1280, 720); + GraphEditorSession session = session(screen); + for (int index = 0; index < 5; index++) { + session.createVariable(); + } + + Object layout = invoke(screen, "debugPanelLayout"); + Rect panel = rect(layout); + List buttons = invokeList(screen, "debugButtons", layout); + List rows = invokeList(screen, "variableRows", layout); + + for (Object button : buttons) { + assertTrue(contains(panel, rect(button)), "button escaped debug panel: " + button); + } + for (Object row : rows) { + assertTrue(contains(panel, rect(row)), "variable row escaped debug panel: " + row); + } + for (Object button : buttons) { + for (Object row : rows) { + assertFalse(intersects(rect(button), rect(row)), "debug button overlaps variable row"); + } + } + } + + private static MCNGDebugScreen createScreen() { + NodeTypeRegistry registry = new NodeTypeRegistry(); + BuiltinNodeRegistrar.registerCoreNodes(registry); + BuiltinNodeRegistrar.registerDebugNodes(registry); + PortTypeRegistry portTypes = MCNGPortTypes.createRegistry(); + NodePaletteRegistry paletteRegistry = new NodePaletteRegistry(); + BuiltinNodePaletteRegistrar.registerAll(paletteRegistry); + GraphDocument document = GraphDocument.of(new GraphDefinition(List.of(), List.of()), new GraphLayout(Map.of())); + Consumer noopPersist = value -> { + }; + Consumer noopStatus = value -> { + }; + return new MCNGDebugScreen( + registry, + portTypes, + paletteRegistry, + new GraphJsonCodec(), + GraphEditorUiConfig.defaultConfig(), + document, + noopPersist, + noopStatus + ); + } + + private static GraphEditorSession session(MCNGDebugScreen screen) throws ReflectiveOperationException { + Field field = MCNGDebugScreen.class.getDeclaredField("session"); + field.setAccessible(true); + return (GraphEditorSession) field.get(screen); + } + + private static void setScreenSize(MCNGDebugScreen screen, int width, int height) throws ReflectiveOperationException { + Field widthField = Screen.class.getDeclaredField("width"); + Field heightField = Screen.class.getDeclaredField("height"); + widthField.setAccessible(true); + heightField.setAccessible(true); + widthField.setInt(screen, width); + heightField.setInt(screen, height); + } + + private static Object invoke(MCNGDebugScreen screen, String methodName) throws ReflectiveOperationException { + Method method = MCNGDebugScreen.class.getDeclaredMethod(methodName); + method.setAccessible(true); + return method.invoke(screen); + } + + @SuppressWarnings("unchecked") + private static List invokeList(MCNGDebugScreen screen, String methodName, Object layout) throws ReflectiveOperationException { + Method method = MCNGDebugScreen.class.getDeclaredMethod(methodName, layout.getClass()); + method.setAccessible(true); + return (List) method.invoke(screen, layout); + } + + private static Rect rect(Object value) throws ReflectiveOperationException { + Method x = value.getClass().getDeclaredMethod("x"); + Method y = value.getClass().getDeclaredMethod("y"); + Method width = value.getClass().getDeclaredMethod("width"); + Method height = value.getClass().getDeclaredMethod("height"); + x.setAccessible(true); + y.setAccessible(true); + width.setAccessible(true); + height.setAccessible(true); + return new Rect((int) x.invoke(value), (int) y.invoke(value), (int) width.invoke(value), (int) height.invoke(value)); + } + + private static boolean contains(Rect outer, Rect inner) { + return inner.x >= outer.x + && inner.y >= outer.y + && inner.x + inner.width <= outer.x + outer.width + && inner.y + inner.height <= outer.y + outer.height; + } + + private static boolean intersects(Rect left, Rect right) { + return left.x < right.x + right.width + && left.x + left.width > right.x + && left.y < right.y + right.height + && left.y + left.height > right.y; + } + + private record Rect(int x, int y, int width, int height) { + } +} diff --git a/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/NodeBodyComponentIntegrationTest.java b/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/NodeBodyComponentIntegrationTest.java new file mode 100644 index 0000000..5fdd4cc --- /dev/null +++ b/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/NodeBodyComponentIntegrationTest.java @@ -0,0 +1,368 @@ +package com.github.squi2rel.mcng.fabric.client; + +import com.github.squi2rel.mcng.core.GraphBuilder; +import com.github.squi2rel.mcng.core.GraphDocument; +import com.github.squi2rel.mcng.core.GraphJsonCodec; +import com.github.squi2rel.mcng.core.GraphLayout; +import com.github.squi2rel.mcng.core.MCNGPortTypes; +import com.github.squi2rel.mcng.core.NodeId; +import com.github.squi2rel.mcng.core.NodePosition; +import com.github.squi2rel.mcng.core.NodeSize; +import com.github.squi2rel.mcng.core.NodeTypeRegistry; +import com.github.squi2rel.mcng.core.PortTypeRegistry; +import com.github.squi2rel.mcng.core.builtin.BuiltinNodeTypes; +import org.junit.jupiter.api.Test; +import org.lwjgl.glfw.GLFW; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +class NodeBodyComponentIntegrationTest { + @Test + void nodeWidgetAllocatesBodyFromStoredLayoutSize() { + NodeTypeRegistry registry = registry(); + PortTypeRegistry portTypes = MCNGPortTypes.createRegistry(); + GraphBuilder builder = new GraphBuilder(registry, portTypes); + NodeId add = builder.addNode(BuiltinNodeTypes.ADD, BuiltinNodeTypes.EmptyConfig.INSTANCE, new NodePosition(20, 20)); + builder.setNodeSize(add, new NodeSize(260, 220)); + + GraphEditorSession session = session(registry, portTypes, builder.buildDocument()); + GraphLayout layout = new GraphLayout(session.positions(), session.sizes()); + GraphViewportState viewport = new GraphViewportState(); + NodeWidget widget = new NodeWidget( + session.node(add), + session.nodeType(session.node(add)), + session, + layout, + viewport, + GraphEditorUiConfig.defaultConfig(), + new FixedBodyComponent(), + ResizePolicy.allSides() + ); + + assertEquals(260, widget.width()); + assertEquals(220, widget.height()); + assertNotNull(widget.bodyBounds()); + assertTrue(widget.bodyBounds().height() >= 90); + assertTrue(widget.minWidth() >= 196); + assertTrue(widget.minHeight() > 0); + } + + @Test + void canvasRoutesFocusAndKeyboardToBodyComponent() { + NodeTypeRegistry registry = registry(); + PortTypeRegistry portTypes = MCNGPortTypes.createRegistry(); + GraphBuilder builder = new GraphBuilder(registry, portTypes); + NodeId add = builder.addNode(BuiltinNodeTypes.ADD, BuiltinNodeTypes.EmptyConfig.INSTANCE, new NodePosition(20, 20)); + + GraphEditorSession session = session(registry, portTypes, builder.buildDocument()); + TrackingBodyComponent component = new TrackingBodyComponent(); + NodeComponentRegistry components = new NodeComponentRegistry() + .register(new NodeComponentDefinition(BuiltinNodeTypes.ADD.id(), () -> component, ResizePolicy.allSides())); + GraphCanvasComponent canvas = new GraphCanvasComponent(session, new GraphInteractionController(), GraphEditorUiConfig::defaultConfig, components); + canvas.init(null, new GraphEditorBounds(0, 0, 800, 480)); + + NodeWidget widget = new NodeWidget( + session.node(add), + session.nodeType(session.node(add)), + session, + new GraphLayout(session.positions(), session.sizes()), + canvas.viewport(), + GraphEditorUiConfig.defaultConfig(), + component, + ResizePolicy.allSides() + ); + double clickX = canvas.viewport().toScreenX(widget.bodyBounds().x() + 12); + double clickY = canvas.viewport().toScreenY(widget.bodyBounds().y() + 12); + + canvas.prepareForClick(clickX, clickY, 0); + assertTrue(canvas.mouseClicked(clickX, clickY, 0)); + assertTrue(canvas.keyPressed(GLFW.GLFW_KEY_A, 0, 0)); + canvas.prepareForClick(clickX + 400, clickY + 200, 0); + + assertEquals(1, component.clickCount); + assertEquals(1, component.keyPressCount); + assertEquals(1, component.blurCount); + } + + @Test + void resizeHandleUpdatesLayoutSize() { + NodeTypeRegistry registry = registry(); + PortTypeRegistry portTypes = MCNGPortTypes.createRegistry(); + GraphBuilder builder = new GraphBuilder(registry, portTypes); + NodeId add = builder.addNode(BuiltinNodeTypes.ADD, BuiltinNodeTypes.EmptyConfig.INSTANCE, new NodePosition(20, 20)); + + GraphEditorSession session = session(registry, portTypes, builder.buildDocument()); + TrackingBodyComponent component = new TrackingBodyComponent(); + NodeComponentRegistry components = new NodeComponentRegistry() + .register(new NodeComponentDefinition(BuiltinNodeTypes.ADD.id(), () -> component, ResizePolicy.allSides())); + GraphInteractionController controller = new GraphInteractionController(); + GraphCanvasComponent canvas = new GraphCanvasComponent(session, controller, GraphEditorUiConfig::defaultConfig, components); + canvas.init(null, new GraphEditorBounds(0, 0, 800, 480)); + + NodeWidget widget = new NodeWidget( + session.node(add), + session.nodeType(session.node(add)), + session, + new GraphLayout(session.positions(), session.sizes()), + canvas.viewport(), + GraphEditorUiConfig.defaultConfig(), + component, + ResizePolicy.allSides() + ); + double handleX = canvas.viewport().toScreenX(widget.x() + widget.width() - 1); + double handleY = canvas.viewport().toScreenY(widget.y() + widget.height() - 1); + + assertNotNull(canvas.resizeTargetAtLocal(handleX, handleY)); + assertTrue(controller.mouseClicked(canvas, handleX, handleY, 0)); + assertTrue(controller.mouseDragged(canvas, handleX + 36, handleY + 24, 0, 36, 24)); + assertTrue(controller.mouseReleased(canvas, handleX + 36, handleY + 24, 0)); + + NodeSize resized = session.sizes().get(add); + assertNotNull(resized); + assertEquals(widget.width() + 36, resized.width()); + assertEquals(widget.height() + 24, resized.height()); + } + + @Test + void rerouteCornerDragKeepsFixedPortAndCanSwitchOrientationMultipleTimes() { + NodeTypeRegistry registry = registry(); + PortTypeRegistry portTypes = MCNGPortTypes.createRegistry(); + GraphBuilder builder = new GraphBuilder(registry, portTypes); + NodeId reroute = builder.addNode(BuiltinNodeTypes.REROUTE, BuiltinNodeTypes.EmptyConfig.INSTANCE, new NodePosition(20, 20)); + + GraphEditorSession session = session(registry, portTypes, builder.buildDocument()); + GraphInteractionController controller = new GraphInteractionController(); + GraphCanvasComponent canvas = new GraphCanvasComponent(session, controller, GraphEditorUiConfig::defaultConfig, new NodeComponentRegistry()); + canvas.init(null, new GraphEditorBounds(0, 0, 800, 480)); + + NodeWidget widget = new NodeWidget( + session.node(reroute), + session.nodeType(session.node(reroute)), + session, + new GraphLayout(session.positions(), session.sizes()), + canvas.viewport() + ); + assertNotNull(widget); + NodeWidget.PortWidget fixedPort = GraphCanvasComponent.findPortWidget(widget, BuiltinNodeTypes.VALUE_PORT, com.github.squi2rel.mcng.core.PortDirection.INPUT); + assertNotNull(fixedPort); + int fixedPortX = fixedPort.centerX(); + int fixedPortY = fixedPort.centerY(); + double handleX = canvas.viewport().toScreenX(widget.x() + widget.width() - 2); + double handleY = canvas.viewport().toScreenY(widget.y() + 1); + + assertNotNull(canvas.resizeTargetAtLocal(handleX, handleY)); + assertTrue(controller.mouseClicked(canvas, handleX, handleY, 0)); + assertTrue(controller.mouseDragged(canvas, canvas.viewport().toScreenX(fixedPortX + 10), canvas.viewport().toScreenY(fixedPortY + 90), 0, 0, 0)); + assertEquals(RerouteOrientation.TOP_TO_BOTTOM, session.rerouteOrientation(reroute)); + + NodeWidget verticalWidget = new NodeWidget( + session.node(reroute), + session.nodeType(session.node(reroute)), + session, + new GraphLayout(session.positions(), session.sizes()), + canvas.viewport() + ); + NodeWidget.PortWidget fixedPortAfterVertical = GraphCanvasComponent.findPortWidget(verticalWidget, BuiltinNodeTypes.VALUE_PORT, com.github.squi2rel.mcng.core.PortDirection.INPUT); + assertNotNull(fixedPortAfterVertical); + assertEquals(fixedPortX, fixedPortAfterVertical.centerX()); + assertEquals(fixedPortY, fixedPortAfterVertical.centerY()); + + assertTrue(controller.mouseDragged(canvas, canvas.viewport().toScreenX(fixedPortX + 120), canvas.viewport().toScreenY(fixedPortY + 10), 0, 0, 0)); + assertEquals(RerouteOrientation.LEFT_TO_RIGHT, session.rerouteOrientation(reroute)); + assertTrue(controller.mouseReleased(canvas, canvas.viewport().toScreenX(fixedPortX + 120), canvas.viewport().toScreenY(fixedPortY + 10), 0)); + + NodeWidget finalWidget = new NodeWidget( + session.node(reroute), + session.nodeType(session.node(reroute)), + session, + new GraphLayout(session.positions(), session.sizes()), + canvas.viewport() + ); + NodeWidget.PortWidget fixedPortAfterReturn = GraphCanvasComponent.findPortWidget(finalWidget, BuiltinNodeTypes.VALUE_PORT, com.github.squi2rel.mcng.core.PortDirection.INPUT); + assertNotNull(fixedPortAfterReturn); + assertEquals(fixedPortX, fixedPortAfterReturn.centerX()); + assertEquals(fixedPortY, fixedPortAfterReturn.centerY()); + } + + @Test + void imagePreviewBodyUsesHostFileChooserToUpdateConfig() { + NodeTypeRegistry registry = registry(); + PortTypeRegistry portTypes = MCNGPortTypes.createRegistry(); + GraphBuilder builder = new GraphBuilder(registry, portTypes); + NodeId preview = builder.addNode(BuiltinNodeTypes.IMAGE_PREVIEW, new BuiltinNodeTypes.ImagePreviewConfig(""), new NodePosition(20, 20)); + + FileChoosingHost host = new FileChoosingHost("/tmp/demo.png"); + GraphEditorSession session = new GraphEditorSession(registry, portTypes, new GraphJsonCodec(), builder.buildDocument(), host); + ImagePreviewNodeBodyComponent component = new ImagePreviewNodeBodyComponent(); + NodeWidget widget = new NodeWidget( + session.node(preview), + session.nodeType(session.node(preview)), + session, + new GraphLayout(session.positions(), session.sizes()), + new GraphViewportState(), + GraphEditorUiConfig.defaultConfig(), + component, + ResizePolicy.allSides() + ); + NodeWidget.Bounds bodyBounds = widget.bodyBounds(); + int previewHeight = Math.max(30, bodyBounds.height() - 52); + double localX = (bodyBounds.width() - 44 - 6 - 58) + 10; + double localY = previewHeight + 4 + 18 + 4 + 9; + + NodeInteractionResult result = component.mouseClicked( + new NodeBodyInputContext(bodyBounds, session.node(preview), session.nodeType(session.node(preview)), session, session.i18n(), GraphEditorUiConfig.defaultConfig(), GraphEditorTheme.defaultTheme(), 1.0), + localX, + localY, + 0 + ); + + assertTrue(result.handled()); + assertEquals("/tmp/demo.png", session.node(preview).config().get("filePath").getAsString()); + assertNotNull(host.request); + assertEquals(List.of("png", "jpg", "jpeg", "bmp", "tga"), host.request.extensions()); + } + + @Test + void bodyInputContextUsesHostTranslator() { + NodeTypeRegistry registry = registry(); + PortTypeRegistry portTypes = MCNGPortTypes.createRegistry(); + GraphBuilder builder = new GraphBuilder(registry, portTypes); + NodeId add = builder.addNode(BuiltinNodeTypes.ADD, BuiltinNodeTypes.EmptyConfig.INSTANCE, new NodePosition(20, 20)); + + TranslatingHost host = new TranslatingHost(Map.of("custom.test", "translated")); + GraphEditorSession session = new GraphEditorSession(registry, portTypes, new GraphJsonCodec(), builder.buildDocument(), host); + NodeBodyInputContext context = new NodeBodyInputContext( + new NodeWidget.Bounds(0, 0, 120, 60), + session.node(add), + session.nodeType(session.node(add)), + session, + session.i18n(), + GraphEditorUiConfig.defaultConfig(), + GraphEditorTheme.defaultTheme(), + 1.0 + ); + + assertEquals("translated", context.translate("custom.test", "fallback")); + } + + private static GraphEditorSession session(NodeTypeRegistry registry, PortTypeRegistry portTypes, GraphDocument document) { + return new GraphEditorSession(registry, portTypes, new GraphJsonCodec(), document, new TestHost()); + } + + private static NodeTypeRegistry registry() { + NodeTypeRegistry registry = new NodeTypeRegistry(); + BuiltinNodeTypes.registerAll(registry); + return registry; + } + + private static final class FixedBodyComponent implements NodeBodyComponent { + @Override + public NodeBodyMeasurement measure(NodeBodyMeasureContext context) { + return new NodeBodyMeasurement(true, 120, 90, 140, 90); + } + } + + private static final class TrackingBodyComponent implements NodeBodyComponent { + private int clickCount; + private int keyPressCount; + private int blurCount; + + @Override + public NodeBodyMeasurement measure(NodeBodyMeasureContext context) { + return new NodeBodyMeasurement(true, 120, 60, 140, 60); + } + + @Override + public NodeInteractionResult mouseClicked(NodeBodyInputContext context, double localMouseX, double localMouseY, int button) { + clickCount++; + return NodeInteractionResult.focusHandled(); + } + + @Override + public boolean keyPressed(NodeBodyInputContext context, int keyCode, int scanCode, int modifiers) { + keyPressCount++; + return true; + } + + @Override + public void blur() { + blurCount++; + } + } + + private static class TestHost implements GraphEditorHost { + @Override + public void onDocumentChanged(GraphDocument document) { + } + + @Override + public void copyToClipboard(String value) { + } + + @Override + public String readClipboard() { + return ""; + } + + @Override + public void showMessage(String message) { + } + } + + private static final class FileChoosingHost implements GraphEditorHost { + private final String chosenPath; + private GraphFileDialogRequest request; + + private FileChoosingHost(String chosenPath) { + this.chosenPath = chosenPath; + } + + @Override + public void onDocumentChanged(GraphDocument document) { + } + + @Override + public void copyToClipboard(String value) { + } + + @Override + public String readClipboard() { + return ""; + } + + @Override + public void showMessage(String message) { + } + + @Override + public boolean supportsFileDialogs() { + return true; + } + + @Override + public Optional chooseFile(GraphFileDialogRequest request) { + this.request = request; + return Optional.of(chosenPath); + } + } + + private static final class TranslatingHost extends TestHost { + private final Map translations; + + private TranslatingHost(Map translations) { + this.translations = translations; + } + + @Override + public GraphEditorI18n i18n() { + return (key, fallback, args) -> translations.getOrDefault(key, GraphEditorI18n.formatFallback(fallback, key, args)); + } + } +} diff --git a/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/NodePaletteCatalogTest.java b/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/NodePaletteCatalogTest.java new file mode 100644 index 0000000..70f5865 --- /dev/null +++ b/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/NodePaletteCatalogTest.java @@ -0,0 +1,179 @@ +package com.github.squi2rel.mcng.fabric.client; + +import com.github.squi2rel.mcng.core.DocumentNodeDefinitionKind; +import com.github.squi2rel.mcng.core.GraphDocument; +import com.github.squi2rel.mcng.core.GraphJsonCodec; +import com.github.squi2rel.mcng.core.GraphLayout; +import com.github.squi2rel.mcng.core.GraphDefinition; +import com.github.squi2rel.mcng.core.MCNGPortTypes; +import com.github.squi2rel.mcng.core.NodeTypeRegistry; +import com.github.squi2rel.mcng.core.PortTypeRegistry; +import com.github.squi2rel.mcng.core.builtin.BuiltinNodeRegistrar; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +class NodePaletteCatalogTest { + @Test + void groupsRegisteredPaletteEntriesIntoExpectedSections() { + NodeTypeRegistry registry = registry(); + PortTypeRegistry portTypes = MCNGPortTypes.createRegistry(); + NodePaletteRegistry paletteRegistry = paletteRegistry(); + GraphEditorSession session = new GraphEditorSession(registry, portTypes, new GraphJsonCodec(), emptyDocument(), new TestHost()); + + List sections = NodePaletteCatalog.buildSections(session, paletteRegistry); + + assertEquals(List.of("Constants", "Operations", "Control", "Variables", "Events", "Debug"), sections.stream().map(NodePaletteSection::title).toList()); + assertEquals(List.of("Numeric Constant", "Boolean Constant", "String Constant", "Port Type"), sections.getFirst().entries().stream().map(NodePaletteEntry::displayName).toList()); + assertEquals( + List.of( + "Add", + "Subtract", + "Multiply", + "Cast", + "Round", + "Concat", + "Less Than", + "Equals", + "Select", + "And", + "Or", + "Identity", + "Make List", + "List Append", + "List Get", + "Map Put", + "Map Get", + "Map Keys", + "Type Of", + "Convert Type", + "Reroute", + "Not" + ), + sections.get(1).entries().stream().map(NodePaletteEntry::displayName).toList() + ); + } + + @Test + void filtersByDisplayNameTypeIdGroupAndSearchKeywords() { + GraphEditorSession session = new GraphEditorSession(registry(), MCNGPortTypes.createRegistry(), new GraphJsonCodec(), emptyDocument(), new TestHost()); + List sections = NodePaletteCatalog.buildSections(session, paletteRegistry()); + + assertEquals(List.of("Debug"), NodePaletteCatalog.filterSections(sections, "debug").stream().map(NodePaletteSection::title).toList()); + assertEquals(List.of("Image Preview"), NodePaletteCatalog.filterSections(sections, "demo").getFirst().entries().stream().map(NodePaletteEntry::displayName).toList()); + assertEquals(List.of("Numeric Constant"), NodePaletteCatalog.filterSections(sections, "numeric_constant").getFirst().entries().stream().map(NodePaletteEntry::displayName).toList()); + assertTrue(NodePaletteCatalog.filterSections(sections, "math").getFirst().entries().stream().anyMatch(entry -> entry.displayName().equals("Add"))); + } + + @Test + void customDefinitionsAppearInPaletteAndResolvedRegistry() { + NodeTypeRegistry registry = registry(); + PortTypeRegistry portTypes = MCNGPortTypes.createRegistry(); + GraphEditorSession session = new GraphEditorSession(registry, portTypes, new GraphJsonCodec(), emptyDocument(), new TestHost()); + + var definition = session.createBlankDefinition(DocumentNodeDefinitionKind.CUSTOM_NODE, 40, 40); + session.exitToBreadcrumb(null); + List sections = NodePaletteCatalog.buildSections(session, paletteRegistry()); + boolean hasCustomEntry = sections.stream() + .filter(section -> section.title().equals("Custom")) + .flatMap(section -> section.entries().stream()) + .anyMatch(entry -> entry.nodeTypeId().equals(com.github.squi2rel.mcng.core.DocumentNodeTypes.definitionTypeId(definition.id()))); + + assertTrue(session.resolvedRegistry().find(com.github.squi2rel.mcng.core.DocumentNodeTypes.definitionTypeId(definition.id())).isPresent()); + assertTrue(hasCustomEntry); + } + + @Test + void editorsCanExposeDifferentPaletteEntriesForSameRuntimeRegistry() { + NodeTypeRegistry registry = registry(); + PortTypeRegistry portTypes = MCNGPortTypes.createRegistry(); + GraphEditorSession session = new GraphEditorSession(registry, portTypes, new GraphJsonCodec(), emptyDocument(), new TestHost()); + + NodePaletteRegistry fullPalette = paletteRegistry(); + NodePaletteRegistry minimalPalette = new NodePaletteRegistry() + .register(new NodePaletteDefinition(com.github.squi2rel.mcng.core.builtin.BuiltinNodeTypes.ADD.id(), "Math", 100, 10)); + + assertTrue(NodePaletteCatalog.buildSections(session, fullPalette).stream().flatMap(section -> section.entries().stream()).anyMatch(entry -> entry.displayName().equals("Debug Output"))); + assertTrue(NodePaletteCatalog.buildSections(session, fullPalette).stream().flatMap(section -> section.entries().stream()).anyMatch(entry -> entry.displayName().equals("Image Preview"))); + assertEquals( + List.of("Add"), + NodePaletteCatalog.buildSections(session, minimalPalette).getFirst().entries().stream().map(NodePaletteEntry::displayName).toList() + ); + } + + @Test + void paletteUsesHostTranslationsAndStillSupportsFallbackSearchTerms() { + NodeTypeRegistry registry = registry(); + PortTypeRegistry portTypes = MCNGPortTypes.createRegistry(); + GraphEditorSession session = new GraphEditorSession( + registry, + portTypes, + new GraphJsonCodec(), + emptyDocument(), + new TranslatingHost(Map.of( + "mcng.ui.palette.section.operations", "运算", + "mcng.node.mcng.add.title", "加法" + )) + ); + + List sections = NodePaletteCatalog.buildSections(session, paletteRegistry()); + + assertTrue(sections.stream().anyMatch(section -> section.title().equals("运算"))); + assertTrue(sections.stream().filter(section -> section.title().equals("运算")).flatMap(section -> section.entries().stream()).anyMatch(entry -> entry.displayName().equals("加法"))); + assertTrue(NodePaletteCatalog.filterSections(sections, "add").stream().flatMap(section -> section.entries().stream()).anyMatch(entry -> entry.displayName().equals("加法"))); + } + + private static NodeTypeRegistry registry() { + NodeTypeRegistry registry = new NodeTypeRegistry(); + BuiltinNodeRegistrar.registerCoreNodes(registry); + BuiltinNodeRegistrar.registerDebugNodes(registry); + return registry; + } + + private static NodePaletteRegistry paletteRegistry() { + NodePaletteRegistry registry = new NodePaletteRegistry(); + BuiltinNodePaletteRegistrar.registerAll(registry); + return registry; + } + + private static GraphDocument emptyDocument() { + return GraphDocument.of(new GraphDefinition(List.of(), List.of()), new GraphLayout(Map.of())); + } + + private static class TestHost implements GraphEditorHost { + @Override + public void onDocumentChanged(GraphDocument document) { + } + + @Override + public void copyToClipboard(String value) { + } + + @Override + public String readClipboard() { + return ""; + } + + @Override + public void showMessage(String message) { + } + } + + private static final class TranslatingHost extends TestHost { + private final Map translations; + + private TranslatingHost(Map translations) { + this.translations = translations; + } + + @Override + public GraphEditorI18n i18n() { + return (key, fallback, args) -> translations.getOrDefault(key, GraphEditorI18n.formatFallback(fallback, key, args)); + } + } +} diff --git a/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/NodePaletteComponentTest.java b/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/NodePaletteComponentTest.java new file mode 100644 index 0000000..dadda4f --- /dev/null +++ b/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/NodePaletteComponentTest.java @@ -0,0 +1,59 @@ +package com.github.squi2rel.mcng.fabric.client; + +import com.github.squi2rel.mcng.core.MCNGPortTypes; +import com.github.squi2rel.mcng.core.NodeTypeRegistry; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.stream.IntStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +class NodePaletteComponentTest { + @Test + void mouseWheelScrollsPaletteContent() throws ReflectiveOperationException { + NodePaletteComponent palette = createPalette(); + + assertTrue(palette.mouseScrolled(40, 120, 0.0, -1.0)); + assertEquals(18.0, scrollOffset(palette)); + } + + @Test + void fallsBackToHorizontalAxisWhenVerticalDeltaIsZero() throws ReflectiveOperationException { + NodePaletteComponent palette = createPalette(); + + assertTrue(palette.mouseScrolled(40, 120, -1.0, 0.0)); + assertEquals(18.0, scrollOffset(palette)); + } + + private static NodePaletteComponent createPalette() { + List entries = IntStream.range(0, 20) + .mapToObj(index -> new NodePaletteEntry("test:" + index, "Node " + index, "test:" + index, "Test", index, List.of())) + .toList(); + NodePaletteComponent palette = new NodePaletteComponent( + () -> List.of(new NodePaletteSection("Test", 0, entries)), + NodeTypeRegistry::new, + MCNGPortTypes.createRegistry(), + GraphEditorUiConfig::defaultConfig, + GraphEditorI18n::identity, + () -> "", + value -> { + } + ); + palette.setBounds(new GraphEditorBounds(0, 0, 320, 160)); + palette.toggle(); + return palette; + } + + private static double scrollOffset(NodePaletteComponent palette) throws ReflectiveOperationException { + Field stateField = NodePaletteComponent.class.getDeclaredField("state"); + stateField.setAccessible(true); + Object state = stateField.get(palette); + Field scrollOffsetField = state.getClass().getDeclaredField("scrollOffset"); + scrollOffsetField.setAccessible(true); + return scrollOffsetField.getDouble(state); + } +} diff --git a/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/NodeRenderDetailLevelTest.java b/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/NodeRenderDetailLevelTest.java new file mode 100644 index 0000000..7c2b0b3 --- /dev/null +++ b/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/NodeRenderDetailLevelTest.java @@ -0,0 +1,19 @@ +package com.github.squi2rel.mcng.fabric.client; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class NodeRenderDetailLevelTest { + @Test + void usesFullDetailAboveMinimumZoom() { + assertEquals(NodeRenderDetailLevel.FULL, NodeRenderDetailLevel.fromZoom(1.0)); + assertEquals(NodeRenderDetailLevel.FULL, NodeRenderDetailLevel.fromZoom(0.84)); + assertEquals(NodeRenderDetailLevel.FULL, NodeRenderDetailLevel.fromZoom(GraphViewportState.MIN_ZOOM + 0.01)); + } + + @Test + void usesMinimalOnlyAtMinimumZoom() { + assertEquals(NodeRenderDetailLevel.MINIMAL, NodeRenderDetailLevel.fromZoom(GraphViewportState.MIN_ZOOM)); + } +} diff --git a/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/NodeWidgetInlineLayoutTest.java b/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/NodeWidgetInlineLayoutTest.java new file mode 100644 index 0000000..53b7090 --- /dev/null +++ b/mcng-fabric-client-26.2/src/test/java/com/github/squi2rel/mcng/fabric/client/NodeWidgetInlineLayoutTest.java @@ -0,0 +1,509 @@ +package com.github.squi2rel.mcng.fabric.client; + +import com.github.squi2rel.mcng.core.GraphBuilder; +import com.github.squi2rel.mcng.core.GraphDefinition; +import com.github.squi2rel.mcng.core.GraphDocument; +import com.github.squi2rel.mcng.core.GraphErrorCode; +import com.github.squi2rel.mcng.core.GraphJsonCodec; +import com.github.squi2rel.mcng.core.GraphLayout; +import com.github.squi2rel.mcng.core.GraphScope; +import com.github.squi2rel.mcng.core.MCNGPortTypes; +import com.github.squi2rel.mcng.core.NodeConfigValues; +import com.github.squi2rel.mcng.core.NodeId; +import com.github.squi2rel.mcng.core.NodeInstance; +import com.github.squi2rel.mcng.core.NodePosition; +import com.github.squi2rel.mcng.core.NodeTypeRegistry; +import com.github.squi2rel.mcng.core.PortDirection; +import com.github.squi2rel.mcng.core.PortId; +import com.github.squi2rel.mcng.core.PortInlineWidget; +import com.github.squi2rel.mcng.core.PortTypeRegistry; +import com.github.squi2rel.mcng.core.SubgraphDefinition; +import com.github.squi2rel.mcng.core.DocumentNodeTypes; +import com.github.squi2rel.mcng.core.EdgeDefinition; +import com.github.squi2rel.mcng.core.DocumentNodeDefinition; +import com.github.squi2rel.mcng.core.builtin.BuiltinNodeTypes; +import com.github.squi2rel.mcng.core.builtin.BuiltinNodeTypes.ManualTriggerConfig; +import com.google.gson.JsonObject; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +import static org.junit.jupiter.api.Assertions.assertFalse; + +class NodeWidgetInlineLayoutTest { + @Test + void fullDetailIncludesPortRowsAndPureControlRows() { + NodeTypeRegistry registry = registry(); + PortTypeRegistry portTypes = MCNGPortTypes.createRegistry(); + GraphBuilder builder = new GraphBuilder(registry, portTypes); + var trigger = builder.addNode(BuiltinNodeTypes.MANUAL_TRIGGER, new ManualTriggerConfig("pulse", true, "boxed"), new NodePosition(20, 20)); + + GraphEditorSession session = session(registry, portTypes, builder.buildDocument()); + NodeWidget widget = widget(session, trigger, 1.0); + + assertEquals(NodeRenderDetailLevel.FULL, widget.detailLevel()); + assertEquals(6, widget.rows().size()); + assertTrue(widget.rows().stream().anyMatch(row -> row instanceof NodeWidget.BooleanControlRowWidget)); + assertTrue(widget.rows().stream().anyMatch(row -> row instanceof NodeWidget.CycleControlRowWidget)); + } + + @Test + void mixedControlAndDataPortsExposeAChannelSeparator() { + NodeTypeRegistry registry = registry(); + PortTypeRegistry portTypes = MCNGPortTypes.createRegistry(); + GraphBuilder builder = new GraphBuilder(registry, portTypes); + var debug = builder.addNode(BuiltinNodeTypes.DEBUG_OUTPUT, BuiltinNodeTypes.EmptyConfig.INSTANCE, new NodePosition(20, 20)); + + GraphEditorSession session = session(registry, portTypes, builder.buildDocument()); + NodeWidget widget = widget(session, debug, 1.0); + + assertEquals(1, widget.channelSeparators().size()); + } + + @Test + void connectedInputHidesItsInlineField() { + NodeTypeRegistry registry = registry(); + PortTypeRegistry portTypes = MCNGPortTypes.createRegistry(); + GraphBuilder builder = new GraphBuilder(registry, portTypes); + var source = builder.addNode(BuiltinNodeTypes.NUMERIC_CONSTANT, new BuiltinNodeTypes.NumericConstantConfig("7.0"), new NodePosition(20, 20)); + var add = builder.addNode(BuiltinNodeTypes.ADD, BuiltinNodeTypes.EmptyConfig.INSTANCE, new NodePosition(220, 20)); + builder.addEdge(source, "value", add, "left"); + + GraphEditorSession session = session(registry, portTypes, builder.buildDocument()); + NodeWidget widget = widget(session, add, 1.0); + var inputRows = widget.rows().stream() + .filter(NodeWidget.InputPortRowWidget.class::isInstance) + .map(NodeWidget.InputPortRowWidget.class::cast) + .toList(); + + assertEquals(2, inputRows.size()); + assertEquals("left", inputRows.getFirst().port().definition().id().value()); + assertNull(inputRows.getFirst().fieldBounds()); + assertEquals("right", inputRows.get(1).port().definition().id().value()); + assertNotNull(inputRows.get(1).fieldBounds()); + } + + @Test + void minimumZoomKeepsLayoutButHidesInteractiveContent() { + NodeTypeRegistry registry = registry(); + PortTypeRegistry portTypes = MCNGPortTypes.createRegistry(); + GraphBuilder builder = new GraphBuilder(registry, portTypes); + var trigger = builder.addNode(BuiltinNodeTypes.MANUAL_TRIGGER, new ManualTriggerConfig("pulse", false, "plain"), new NodePosition(20, 20)); + + GraphEditorSession session = session(registry, portTypes, builder.buildDocument()); + NodeWidget fullWidget = widget(session, trigger, 1.0); + NodeWidget minimalWidget = widget(session, trigger, GraphViewportState.MIN_ZOOM); + + assertEquals(NodeRenderDetailLevel.FULL, fullWidget.detailLevel()); + assertEquals(NodeRenderDetailLevel.MINIMAL, minimalWidget.detailLevel()); + assertEquals(fullWidget.height(), minimalWidget.height()); + assertEquals(fullWidget.rows().size(), minimalWidget.rows().size()); + assertEquals(fullWidget.ports().size(), minimalWidget.ports().size()); + for (int index = 0; index < fullWidget.ports().size(); index++) { + assertEquals(fullWidget.ports().get(index).centerX(), minimalWidget.ports().get(index).centerX()); + assertEquals(fullWidget.ports().get(index).centerY(), minimalWidget.ports().get(index).centerY()); + } + } + + @Test + void booleanInlinePortUsesInteractiveFieldBounds() { + NodeTypeRegistry registry = registry(); + PortTypeRegistry portTypes = MCNGPortTypes.createRegistry(); + GraphBuilder builder = new GraphBuilder(registry, portTypes); + var not = builder.addNode(BuiltinNodeTypes.NOT, BuiltinNodeTypes.EmptyConfig.INSTANCE, new NodePosition(20, 20)); + + GraphEditorSession session = session(registry, portTypes, builder.buildDocument()); + NodeWidget widget = widget(session, not, 1.0); + var inputRow = widget.rows().stream() + .filter(NodeWidget.InputPortRowWidget.class::isInstance) + .map(NodeWidget.InputPortRowWidget.class::cast) + .findFirst() + .orElseThrow(); + + assertEquals("flag", inputRow.port().definition().id().value()); + assertNotNull(inputRow.fieldBounds()); + } + + @Test + void sessionMarksNodesThatHaveExecutionErrors() { + NodeTypeRegistry registry = registry(); + PortTypeRegistry portTypes = MCNGPortTypes.createRegistry(); + GraphBuilder builder = new GraphBuilder(registry, portTypes); + var add = builder.addNode(BuiltinNodeTypes.ADD, BuiltinNodeTypes.EmptyConfig.INSTANCE, new NodePosition(20, 20)); + builder.setInlineInput(add, "left", NodeConfigValues.stringValue("oops")); + builder.setInlineInput(add, "right", NodeConfigValues.numberValue(7.0)); + + GraphEditorSession session = session(registry, portTypes, builder.buildDocument()); + session.executeGraph(); + var snapshot = session.tickExecution(8); + var result = snapshot.toExecutionResult(); + + assertFalse(result.success()); + assertEquals(GraphErrorCode.INVALID_INLINE_INPUT, result.errors().getFirst().code()); + assertTrue(session.hasError(add)); + } + + @Test + void rerouteUsesCompactSingleRowLayoutWithoutHeader() { + NodeTypeRegistry registry = registry(); + PortTypeRegistry portTypes = MCNGPortTypes.createRegistry(); + GraphBuilder builder = new GraphBuilder(registry, portTypes); + var reroute = builder.addNode(BuiltinNodeTypes.REROUTE, BuiltinNodeTypes.EmptyConfig.INSTANCE, new NodePosition(20, 20)); + var identity = builder.addNode(BuiltinNodeTypes.IDENTITY, BuiltinNodeTypes.EmptyConfig.INSTANCE, new NodePosition(120, 20)); + + GraphEditorSession session = session(registry, portTypes, builder.buildDocument()); + NodeWidget rerouteWidget = widget(session, reroute, 1.0); + NodeWidget identityWidget = widget(session, identity, 1.0); + + assertEquals(0, rerouteWidget.headerHeight()); + assertEquals(0, rerouteWidget.rows().size()); + assertTrue(rerouteWidget.width() < identityWidget.width()); + assertEquals(2, rerouteWidget.ports().size()); + assertEquals(rerouteWidget.ports().getFirst().centerY(), rerouteWidget.ports().getLast().centerY()); + } + + @Test + void canvasPortLookupDistinguishesDirectionWhenIdsMatch() { + NodeTypeRegistry registry = registry(); + PortTypeRegistry portTypes = MCNGPortTypes.createRegistry(); + GraphBuilder builder = new GraphBuilder(registry, portTypes); + var reroute = builder.addNode(BuiltinNodeTypes.REROUTE, BuiltinNodeTypes.EmptyConfig.INSTANCE, new NodePosition(20, 20)); + + GraphEditorSession session = session(registry, portTypes, builder.buildDocument()); + NodeWidget rerouteWidget = widget(session, reroute, 1.0); + + var inputPort = GraphCanvasComponent.findPortWidget(rerouteWidget, new PortId("value"), PortDirection.INPUT); + var outputPort = GraphCanvasComponent.findPortWidget(rerouteWidget, new PortId("value"), PortDirection.OUTPUT); + + assertNotNull(inputPort); + assertNotNull(outputPort); + assertTrue(inputPort.centerX() < outputPort.centerX()); + assertEquals(PortDirection.INPUT, inputPort.definition().direction()); + assertEquals(PortDirection.OUTPUT, outputPort.definition().direction()); + } + + @Test + void flippedRerouteSwapsInputAndOutputSides() { + NodeTypeRegistry registry = registry(); + PortTypeRegistry portTypes = MCNGPortTypes.createRegistry(); + GraphBuilder builder = new GraphBuilder(registry, portTypes); + var source = builder.addNode(BuiltinNodeTypes.NUMERIC_CONSTANT, new BuiltinNodeTypes.NumericConstantConfig("7.0"), new NodePosition(20, 20)); + var reroute = builder.addNode(BuiltinNodeTypes.REROUTE, BuiltinNodeTypes.EmptyConfig.INSTANCE, new NodePosition(160, 20)); + + GraphEditorSession session = session(registry, portTypes, builder.buildDocument()); + assertTrue(session.toggleConnectionCandidate(source, BuiltinNodeTypes.VALUE_PORT, PortDirection.OUTPUT)); + assertTrue(session.toggleConnectionCandidate(reroute, BuiltinNodeTypes.VALUE_PORT, PortDirection.OUTPUT, true)); + + NodeWidget rerouteWidget = widget(session, reroute, 1.0); + var inputPort = GraphCanvasComponent.findPortWidget(rerouteWidget, new PortId("value"), PortDirection.INPUT); + var outputPort = GraphCanvasComponent.findPortWidget(rerouteWidget, new PortId("value"), PortDirection.OUTPUT); + + assertNotNull(inputPort); + assertNotNull(outputPort); + assertTrue(inputPort.centerX() > outputPort.centerX()); + } + + @Test + void verticalRerouteUsesTopAndBottomPorts() { + NodeTypeRegistry registry = registry(); + PortTypeRegistry portTypes = MCNGPortTypes.createRegistry(); + GraphBuilder builder = new GraphBuilder(registry, portTypes); + var reroute = builder.addNode(BuiltinNodeTypes.REROUTE, BuiltinNodeTypes.EmptyConfig.INSTANCE, new NodePosition(20, 20)); + + GraphEditorSession session = session(registry, portTypes, builder.buildDocument()); + session.resizeReroute(reroute, new NodePosition(20, 20), new com.github.squi2rel.mcng.core.NodeSize(16, 92), RerouteOrientation.TOP_TO_BOTTOM); + NodeWidget rerouteWidget = widget(session, reroute, 1.0); + var inputPort = GraphCanvasComponent.findPortWidget(rerouteWidget, new PortId("value"), PortDirection.INPUT); + var outputPort = GraphCanvasComponent.findPortWidget(rerouteWidget, new PortId("value"), PortDirection.OUTPUT); + + assertNotNull(inputPort); + assertNotNull(outputPort); + assertEquals(16, rerouteWidget.width()); + assertEquals(92, rerouteWidget.height()); + assertEquals(NodeWidget.PortSide.TOP, inputPort.side()); + assertEquals(NodeWidget.PortSide.BOTTOM, outputPort.side()); + assertEquals(inputPort.centerX(), outputPort.centerX()); + assertTrue(inputPort.centerY() < outputPort.centerY()); + } + + @Test + void subgraphNodePutsNameFieldOnTopAsFullWidthRow() { + NodeTypeRegistry registry = registry(); + PortTypeRegistry portTypes = MCNGPortTypes.createRegistry(); + GraphEditorSession session = session(registry, portTypes, subgraphDocument()); + NodeWidget widget = widget(session, new NodeId("subgraphNode"), 1.0); + + assertEquals(22, widget.headerHeight()); + assertFalse(widget.rows().isEmpty()); + assertTrue(widget.rows().getFirst() instanceof NodeWidget.TextControlRowWidget); + + NodeWidget.TextControlRowWidget nameRow = (NodeWidget.TextControlRowWidget) widget.rows().getFirst(); + assertFalse(nameRow.labelVisible()); + assertEquals(DocumentNodeTypes.DEFINITION_NAME_CONTROL_KEY, nameRow.control().key()); + assertEquals(widget.y(), nameRow.y()); + assertEquals(widget.headerHeight(), nameRow.height()); + assertEquals(widget.x() + widget.edgePadding(), nameRow.fieldBounds().x()); + assertEquals(widget.width() - (widget.edgePadding() * 2), nameRow.fieldBounds().width()); + assertTrue(nameRow.fieldBounds().y() >= widget.y()); + assertTrue(nameRow.fieldBounds().y() + nameRow.fieldBounds().height() <= widget.y() + widget.headerHeight()); + + NodeWidget.InputPortRowWidget inputRow = widget.rows().stream() + .skip(1) + .filter(NodeWidget.InputPortRowWidget.class::isInstance) + .map(NodeWidget.InputPortRowWidget.class::cast) + .findFirst() + .orElseThrow(); + assertTrue(nameRow.y() < inputRow.y()); + } + + @Test + void customNodePutsNameFieldIntoHeaderAsFullWidthRow() { + NodeTypeRegistry registry = registry(); + PortTypeRegistry portTypes = MCNGPortTypes.createRegistry(); + GraphEditorSession session = session(registry, portTypes, customDocument()); + NodeWidget widget = widget(session, new NodeId("customNode"), 1.0); + + assertEquals(22, widget.headerHeight()); + assertFalse(widget.rows().isEmpty()); + assertTrue(widget.rows().getFirst() instanceof NodeWidget.TextControlRowWidget); + + NodeWidget.TextControlRowWidget nameRow = (NodeWidget.TextControlRowWidget) widget.rows().getFirst(); + assertFalse(nameRow.labelVisible()); + assertEquals(DocumentNodeTypes.DEFINITION_NAME_CONTROL_KEY, nameRow.control().key()); + assertEquals(widget.y(), nameRow.y()); + assertEquals(widget.headerHeight(), nameRow.height()); + assertEquals(widget.x() + widget.edgePadding(), nameRow.fieldBounds().x()); + assertEquals(widget.width() - (widget.edgePadding() * 2), nameRow.fieldBounds().width()); + + NodeWidget.InputPortRowWidget inputRow = widget.rows().stream() + .skip(1) + .filter(NodeWidget.InputPortRowWidget.class::isInstance) + .map(NodeWidget.InputPortRowWidget.class::cast) + .findFirst() + .orElseThrow(); + assertTrue(nameRow.y() < inputRow.y()); + } + + @Test + void customNodeShowsNumericInlineFieldWhenGraphInputEnablesIt() { + NodeTypeRegistry registry = registry(); + PortTypeRegistry portTypes = MCNGPortTypes.createRegistry(); + GraphEditorSession session = session(registry, portTypes, customInlineNumericDocument()); + NodeWidget widget = widget(session, new NodeId("customNode"), 1.0); + + NodeWidget.InputPortRowWidget inputRow = widget.rows().stream() + .filter(NodeWidget.InputPortRowWidget.class::isInstance) + .map(NodeWidget.InputPortRowWidget.class::cast) + .findFirst() + .orElseThrow(); + + assertNotNull(inputRow.fieldBounds()); + assertNotNull(inputRow.port().definition().inlineWidget()); + assertEquals(PortInlineWidget.Kind.NUMERIC_TEXT, inputRow.port().definition().inlineWidget().kind()); + } + + @Test + void subgraphNodeShowsBooleanInlineToggleWhenGraphInputEnablesIt() { + NodeTypeRegistry registry = registry(); + PortTypeRegistry portTypes = MCNGPortTypes.createRegistry(); + GraphEditorSession session = session(registry, portTypes, subgraphInlineBooleanDocument()); + NodeWidget widget = widget(session, new NodeId("subgraphNode"), 1.0); + + NodeWidget.InputPortRowWidget inputRow = widget.rows().stream() + .filter(NodeWidget.InputPortRowWidget.class::isInstance) + .map(NodeWidget.InputPortRowWidget.class::cast) + .findFirst() + .orElseThrow(); + + assertNotNull(inputRow.fieldBounds()); + assertNotNull(inputRow.port().definition().inlineWidget()); + assertEquals(PortInlineWidget.Kind.BOOLEAN_TOGGLE, inputRow.port().definition().inlineWidget().kind()); + } + + private GraphEditorSession session(NodeTypeRegistry registry, PortTypeRegistry portTypes, GraphDocument document) { + return new GraphEditorSession(registry, portTypes, new GraphJsonCodec(), document, new TestHost()); + } + + private NodeWidget widget(GraphEditorSession session, NodeId nodeId, double zoom) { + var node = session.node(nodeId); + GraphLayout layout = new GraphLayout(session.positions(), session.sizes()); + GraphViewportState viewport = new GraphViewportState(); + if (zoom != 1.0) { + viewport.zoomAt(zoom - 1.0, 0, 0); + } + return new NodeWidget(node, session.nodeType(node), session, layout, viewport); + } + + private NodeTypeRegistry registry() { + NodeTypeRegistry registry = new NodeTypeRegistry(); + BuiltinNodeTypes.registerAll(registry); + return registry; + } + + private GraphDocument subgraphDocument() { + GraphDefinition subgraphGraph = new GraphDefinition( + List.of( + new NodeInstance(new NodeId("in"), DocumentNodeTypes.SUBGRAPH_INPUT_TYPE_ID, portConfig("Input")), + new NodeInstance(new NodeId("out"), DocumentNodeTypes.SUBGRAPH_OUTPUT_TYPE_ID, portConfig("Output")) + ), + List.of(new EdgeDefinition(new NodeId("in"), new PortId("value"), new NodeId("out"), new PortId("value"))) + ); + GraphLayout subgraphLayout = new GraphLayout(Map.of( + new NodeId("in"), new NodePosition(20, 20), + new NodeId("out"), new NodePosition(220, 20) + )); + GraphDefinition rootGraph = new GraphDefinition( + List.of( + new NodeInstance(new NodeId("subgraphNode"), DocumentNodeTypes.subgraphTypeId("subgraph"), new JsonObject()) + ), + List.of() + ); + GraphLayout rootLayout = new GraphLayout(Map.of( + new NodeId("subgraphNode"), new NodePosition(20, 20) + )); + return GraphDocument.of( + GraphScope.of( + rootGraph, + rootLayout, + List.of(), + List.of(new SubgraphDefinition("subgraph", "Subgraph", GraphScope.of(subgraphGraph, subgraphLayout))) + ), + List.of() + ); + } + + private GraphDocument customDocument() { + GraphDefinition definitionGraph = new GraphDefinition( + List.of( + new NodeInstance(new NodeId("in"), DocumentNodeTypes.GRAPH_INPUT_TYPE_ID, portConfig("Input")), + new NodeInstance(new NodeId("out"), DocumentNodeTypes.GRAPH_OUTPUT_TYPE_ID, portConfig("Output")) + ), + List.of(new EdgeDefinition(new NodeId("in"), new PortId("value"), new NodeId("out"), new PortId("value"))) + ); + GraphLayout definitionLayout = new GraphLayout(Map.of( + new NodeId("in"), new NodePosition(20, 20), + new NodeId("out"), new NodePosition(220, 20) + )); + GraphDefinition rootGraph = new GraphDefinition( + List.of( + new NodeInstance(new NodeId("customNode"), DocumentNodeTypes.definitionTypeId("custom"), new JsonObject()) + ), + List.of() + ); + GraphLayout rootLayout = new GraphLayout(Map.of( + new NodeId("customNode"), new NodePosition(20, 20) + )); + return GraphDocument.of( + GraphScope.of(rootGraph, rootLayout), + List.of(new DocumentNodeDefinition("custom", "Custom", GraphScope.of(definitionGraph, definitionLayout))) + ); + } + + private GraphDocument customInlineNumericDocument() { + GraphDefinition definitionGraph = new GraphDefinition( + List.of( + new NodeInstance(new NodeId("in"), DocumentNodeTypes.GRAPH_INPUT_TYPE_ID, portConfig("Input", true)), + new NodeInstance(new NodeId("add"), BuiltinNodeTypes.ADD.id(), BuiltinNodeTypes.EmptyConfig.CODEC.toJson(BuiltinNodeTypes.EmptyConfig.INSTANCE)), + new NodeInstance(new NodeId("out"), DocumentNodeTypes.GRAPH_OUTPUT_TYPE_ID, portConfig("Output")) + ), + List.of( + new EdgeDefinition(new NodeId("in"), new PortId("value"), new NodeId("add"), BuiltinNodeTypes.LEFT_PORT), + new EdgeDefinition(new NodeId("add"), BuiltinNodeTypes.VALUE_PORT, new NodeId("out"), new PortId("value")) + ) + ); + GraphLayout definitionLayout = new GraphLayout(Map.of( + new NodeId("in"), new NodePosition(20, 20), + new NodeId("add"), new NodePosition(180, 20), + new NodeId("out"), new NodePosition(360, 20) + )); + GraphDefinition rootGraph = new GraphDefinition( + List.of( + new NodeInstance(new NodeId("customNode"), DocumentNodeTypes.definitionTypeId("custom_inline"), new JsonObject()) + ), + List.of() + ); + GraphLayout rootLayout = new GraphLayout(Map.of( + new NodeId("customNode"), new NodePosition(20, 20) + )); + return GraphDocument.of( + GraphScope.of(rootGraph, rootLayout), + List.of(new DocumentNodeDefinition("custom_inline", "Custom Inline", GraphScope.of(definitionGraph, definitionLayout))) + ); + } + + private GraphDocument subgraphInlineBooleanDocument() { + GraphDefinition subgraphGraph = new GraphDefinition( + List.of( + new NodeInstance(new NodeId("in"), DocumentNodeTypes.SUBGRAPH_INPUT_TYPE_ID, portConfig("Enabled", true)), + new NodeInstance(new NodeId("not"), BuiltinNodeTypes.NOT.id(), BuiltinNodeTypes.EmptyConfig.CODEC.toJson(BuiltinNodeTypes.EmptyConfig.INSTANCE)), + new NodeInstance(new NodeId("out"), DocumentNodeTypes.SUBGRAPH_OUTPUT_TYPE_ID, portConfig("Output")) + ), + List.of( + new EdgeDefinition(new NodeId("in"), new PortId("value"), new NodeId("not"), BuiltinNodeTypes.BOOLEAN_PORT), + new EdgeDefinition(new NodeId("not"), BuiltinNodeTypes.VALUE_PORT, new NodeId("out"), new PortId("value")) + ) + ); + GraphLayout subgraphLayout = new GraphLayout(Map.of( + new NodeId("in"), new NodePosition(20, 20), + new NodeId("not"), new NodePosition(180, 20), + new NodeId("out"), new NodePosition(340, 20) + )); + GraphDefinition rootGraph = new GraphDefinition( + List.of( + new NodeInstance(new NodeId("subgraphNode"), DocumentNodeTypes.subgraphTypeId("subgraph_inline_bool"), new JsonObject()) + ), + List.of() + ); + GraphLayout rootLayout = new GraphLayout(Map.of( + new NodeId("subgraphNode"), new NodePosition(20, 20) + )); + return GraphDocument.of( + GraphScope.of( + rootGraph, + rootLayout, + List.of(), + List.of(new SubgraphDefinition("subgraph_inline_bool", "Inline Bool", GraphScope.of(subgraphGraph, subgraphLayout))) + ), + List.of() + ); + } + + private static JsonObject portConfig(String name) { + return portConfig(name, false); + } + + private static JsonObject portConfig(String name, boolean inlineInput) { + JsonObject json = new JsonObject(); + json.addProperty("name", name); + if (inlineInput) { + json.addProperty("inlineInput", true); + } + return json; + } + + private static final class TestHost implements GraphEditorHost { + @Override + public void onDocumentChanged(GraphDocument document) { + } + + @Override + public void copyToClipboard(String value) { + } + + @Override + public String readClipboard() { + return ""; + } + + @Override + public void showMessage(String message) { + } + } +} diff --git a/mcng-fabric-client/build.gradle b/mcng-fabric-client/build.gradle index 429acc1..188ae47 100644 --- a/mcng-fabric-client/build.gradle +++ b/mcng-fabric-client/build.gradle @@ -1,5 +1,5 @@ plugins { - id 'fabric-loom' version "${loom_version}" + id 'fabric-loom' id 'maven-publish' } @@ -9,7 +9,7 @@ base { dependencies { minecraft "com.mojang:minecraft:${project.minecraft_version}" - mappings "net.fabricmc:yarn:${project.yarn_mappings}:v2" + mappings loom.officialMojangMappings() modImplementation "net.fabricmc:fabric-loader:${project.loader_version}" implementation project(':mcng-core') @@ -28,6 +28,9 @@ tasks.withType(JavaCompile).configureEach { } java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } diff --git a/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/EdgeRenderer.java b/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/EdgeRenderer.java index d3aa2f8..4135338 100644 --- a/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/EdgeRenderer.java +++ b/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/EdgeRenderer.java @@ -1,9 +1,8 @@ package com.github.squi2rel.mcng.fabric.client; -import net.minecraft.client.gui.DrawContext; - import java.util.ArrayList; import java.util.List; +import net.minecraft.client.gui.GuiGraphics; public final class EdgeRenderer { private static final int MIN_CONTROL_OFFSET = 28; @@ -16,7 +15,7 @@ private EdgeRenderer() { } public static void drawEdge( - DrawContext context, + GuiGraphics context, int startX, int startY, int endX, @@ -34,12 +33,12 @@ public static void drawEdge( } } - private static void drawStraight(DrawContext context, int startX, int startY, int endX, int endY, int startColor, int endColor) { + private static void drawStraight(GuiGraphics context, int startX, int startY, int endX, int endY, int startColor, int endColor) { drawGradientSegment(context, startX, startY, endX, endY, startColor, endColor, 0.0, 1.0); } private static void drawOrthogonal( - DrawContext context, + GuiGraphics context, int startX, int startY, int endX, @@ -74,7 +73,7 @@ private static void drawOrthogonal( } private static void drawCurve( - DrawContext context, + GuiGraphics context, int startX, int startY, int endX, @@ -113,7 +112,7 @@ static CurveControls curveControls(int startX, int startY, int endX, int endY, N ); } - private static void drawGradientPolyline(DrawContext context, List points, int startColor, int endColor) { + private static void drawGradientPolyline(GuiGraphics context, List points, int startColor, int endColor) { double totalLength = 0.0; for (int index = 1; index < points.size(); index++) { totalLength += distance(points.get(index - 1), points.get(index)); @@ -134,7 +133,7 @@ private static void drawGradientPolyline(DrawContext context, List points } } - private static void drawGradientOrthogonalPolyline(DrawContext context, List points, int startColor, int endColor) { + private static void drawGradientOrthogonalPolyline(GuiGraphics context, List points, int startColor, int endColor) { double totalLength = 0.0; for (int index = 1; index < points.size(); index++) { totalLength += distance(points.get(index - 1), points.get(index)); @@ -159,7 +158,7 @@ private static void drawGradientOrthogonalPolyline(DrawContext context, List uiConfigSupplier; private final NodeComponentRegistry componentRegistry; private final Map bodyComponents = new LinkedHashMap<>(); - private TextRenderer textRenderer; + private Font textRenderer; private ActiveTextEdit activeTextEdit; private NodeId focusedBodyNodeId; private CapturedBodyInteraction capturedBodyInteraction; @@ -52,7 +52,7 @@ public GraphCanvasComponent(GraphEditorSession session, GraphInteractionControll viewport.reset(); } - public void init(TextRenderer textRenderer, GraphEditorBounds bounds) { + public void init(Font textRenderer, GraphEditorBounds bounds) { this.textRenderer = textRenderer; setBounds(bounds); } @@ -93,7 +93,7 @@ public boolean contains(double mouseX, double mouseY) { return bounds.contains(mouseX, mouseY); } - public void render(DrawContext context, TextRenderer textRenderer, int mouseX, int mouseY) { + public void render(GuiGraphics context, Font textRenderer, int mouseX, int mouseY) { this.textRenderer = textRenderer; GraphEditorUiConfig uiConfig = uiConfigSupplier.get(); GraphEditorTheme theme = uiConfig.theme(); @@ -102,10 +102,10 @@ public void render(DrawContext context, TextRenderer textRenderer, int mouseX, i context.enableScissor(bounds.x(), bounds.y(), bounds.right(), bounds.bottom()); try { List widgets = widgets(); - context.getMatrices().pushMatrix(); - context.getMatrices().translate(bounds.x(), bounds.y()); - context.getMatrices().translate((float) viewport.offsetX(), (float) viewport.offsetY()); - context.getMatrices().scale((float) viewport.zoom(), (float) viewport.zoom()); + context.pose().pushMatrix(); + context.pose().translate(bounds.x(), bounds.y()); + context.pose().translate((float) viewport.offsetX(), (float) viewport.offsetY()); + context.pose().scale((float) viewport.zoom(), (float) viewport.zoom()); try { for (EdgeDefinition edge : session.edges()) { NodeWidget fromNode = widgets.stream().filter(widget -> widget.node().id().equals(edge.fromNodeId())).findFirst().orElse(null); @@ -187,7 +187,7 @@ public void render(DrawContext context, TextRenderer textRenderer, int mouseX, i EditorStyleRenderer.drawBorder(context, (int) selectionBox.minX(), (int) selectionBox.minY(), (int) Math.max(1, selectionBox.maxX() - selectionBox.minX()), (int) Math.max(1, selectionBox.maxY() - selectionBox.minY()), 0xFF6EA8FF); } } finally { - context.getMatrices().popMatrix(); + context.pose().popMatrix(); } } finally { context.disableScissor(); @@ -549,10 +549,10 @@ public List nodesInRect(double minX, double minY, double maxX, double ma return nodeIds; } - private void renderGrid(DrawContext context, GraphEditorTheme theme) { + private void renderGrid(GuiGraphics context, GraphEditorTheme theme) { context.fill(bounds.x(), bounds.y(), bounds.right(), bounds.bottom(), theme.canvasBackgroundColor()); - context.getMatrices().pushMatrix(); - context.getMatrices().translate(bounds.x(), bounds.y()); + context.pose().pushMatrix(); + context.pose().translate(bounds.x(), bounds.y()); try { double step = Math.max(8, 24 * viewport.zoom()); double startX = viewport.offsetX() % step; @@ -564,24 +564,24 @@ private void renderGrid(DrawContext context, GraphEditorTheme theme) { context.fill(0, (int) y, bounds.width(), (int) y + 1, theme.gridColor()); } } finally { - context.getMatrices().popMatrix(); + context.pose().popMatrix(); } } - private void renderActiveTextEditor(DrawContext context, TextRenderer textRenderer, GraphEditorTheme theme, GraphEditorUiConfig uiConfig) { + private void renderActiveTextEditor(GuiGraphics context, Font textRenderer, GraphEditorTheme theme, GraphEditorUiConfig uiConfig) { if (activeTextEdit == null) { return; } context.enableScissor(bounds.x(), bounds.y(), bounds.right(), bounds.bottom()); try { - context.getMatrices().pushMatrix(); + context.pose().pushMatrix(); try { - context.getMatrices().translate(bounds.x(), bounds.y()); - context.getMatrices().translate((float) viewport.offsetX(), (float) viewport.offsetY()); - context.getMatrices().scale((float) viewport.zoom(), (float) viewport.zoom()); + context.pose().translate(bounds.x(), bounds.y()); + context.pose().translate((float) viewport.offsetX(), (float) viewport.offsetY()); + context.pose().scale((float) viewport.zoom(), (float) viewport.zoom()); GraphTextInputRenderer.renderFrame(context, activeTextEdit.bounds(), theme, uiConfig); } finally { - context.getMatrices().popMatrix(); + context.pose().popMatrix(); } NodeWidget.Bounds screenBounds = screenBounds(activeTextEdit.bounds()); @@ -591,14 +591,14 @@ private void renderActiveTextEditor(DrawContext context, TextRenderer textRender int scissorBottom = Math.min(bounds.bottom(), screenBounds.y() + screenBounds.height() - GraphTextInputRenderer.CONTENT_PADDING_Y); context.enableScissor(scissorLeft, scissorTop, scissorRight, scissorBottom); try { - context.getMatrices().pushMatrix(); + context.pose().pushMatrix(); try { - context.getMatrices().translate(bounds.x(), bounds.y()); - context.getMatrices().translate((float) viewport.offsetX(), (float) viewport.offsetY()); - context.getMatrices().scale((float) viewport.zoom(), (float) viewport.zoom()); + context.pose().translate(bounds.x(), bounds.y()); + context.pose().translate((float) viewport.offsetX(), (float) viewport.offsetY()); + context.pose().scale((float) viewport.zoom(), (float) viewport.zoom()); GraphTextInputRenderer.renderContent(context, textRenderer, activeTextEdit.bounds(), activeTextEdit.state(), theme, true); } finally { - context.getMatrices().popMatrix(); + context.pose().popMatrix(); } } finally { context.disableScissor(); @@ -1008,7 +1008,7 @@ private GraphTextInputState state() { return state; } - private void handlePointerDown(TextRenderer textRenderer, double worldX, long timeMs) { + private void handlePointerDown(Font textRenderer, double worldX, long timeMs) { int index = indexForWorldX(textRenderer, worldX); if ((timeMs - lastPointerDownAt) <= DOUBLE_CLICK_WINDOW_MS && Math.abs(index - lastPointerIndex) <= 1) { state.selectWordAt(index); @@ -1022,7 +1022,7 @@ private void handlePointerDown(TextRenderer textRenderer, double worldX, long ti ensureCursorVisible(textRenderer); } - private void handlePointerDrag(TextRenderer textRenderer, double worldX) { + private void handlePointerDrag(Font textRenderer, double worldX) { if (!draggingPointer) { return; } @@ -1034,7 +1034,7 @@ private void finishPointerDrag() { draggingPointer = false; } - private void ensureCursorVisible(TextRenderer textRenderer) { + private void ensureCursorVisible(Font textRenderer) { state.ensureCursorVisible(textRenderer, Math.max(1, bounds.width() - 8)); } @@ -1046,7 +1046,7 @@ private boolean matchesControl(NodeId nodeId, String key) { return this.nodeId.equals(nodeId) && this.key != null && this.key.equals(key); } - private int indexForWorldX(TextRenderer textRenderer, double worldX) { + private int indexForWorldX(Font textRenderer, double worldX) { double localX = worldX - (bounds.x() + 4) + state.scrollX(); return state.indexForX(textRenderer, localX); } diff --git a/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphContextMenuComponent.java b/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphContextMenuComponent.java index 5b59959..315f9ad 100644 --- a/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphContextMenuComponent.java +++ b/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphContextMenuComponent.java @@ -1,10 +1,9 @@ package com.github.squi2rel.mcng.fabric.client; -import net.minecraft.client.font.TextRenderer; -import net.minecraft.client.gui.DrawContext; - import java.util.List; import java.util.function.Supplier; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; public final class GraphContextMenuComponent { private static final int PADDING = 4; @@ -30,7 +29,7 @@ public GraphContextMenuComponent(Supplier uiConfigSupplier, this.i18nSupplier = i18nSupplier; } - public void openNodeMenu(double anchorScreenX, double anchorScreenY, GraphEditorBounds bounds, TextRenderer textRenderer) { + public void openNodeMenu(double anchorScreenX, double anchorScreenY, GraphEditorBounds bounds, Font textRenderer) { open(anchorScreenX, anchorScreenY, bounds, textRenderer, List.of( new MenuItem(GraphEditorTranslations.ui(i18nSupplier.get(), "context_menu.copy", "Copy"), MenuAction.COPY, true), new MenuItem(GraphEditorTranslations.ui(i18nSupplier.get(), "context_menu.cut", "Cut"), MenuAction.CUT, true), @@ -38,7 +37,7 @@ public void openNodeMenu(double anchorScreenX, double anchorScreenY, GraphEditor )); } - public void openCanvasMenu(double anchorScreenX, double anchorScreenY, GraphEditorBounds bounds, TextRenderer textRenderer, boolean canPaste) { + public void openCanvasMenu(double anchorScreenX, double anchorScreenY, GraphEditorBounds bounds, Font textRenderer, boolean canPaste) { open(anchorScreenX, anchorScreenY, bounds, textRenderer, List.of(new MenuItem(GraphEditorTranslations.ui(i18nSupplier.get(), "context_menu.paste", "Paste"), MenuAction.PASTE, canPaste))); } @@ -67,7 +66,7 @@ public MenuAction mouseClicked(double mouseX, double mouseY, int button) { return item.action(); } - public void render(DrawContext context, TextRenderer textRenderer) { + public void render(GuiGraphics context, Font textRenderer) { if (!open) { return; } @@ -83,7 +82,7 @@ public void render(DrawContext context, TextRenderer textRenderer) { : EditorStyleRenderer.darken(theme.panelBackgroundColor(), 0.08f); int border = item.active() ? theme.panelBorderColor() : EditorStyleRenderer.darken(theme.panelBorderColor(), 0.25f); EditorStyleRenderer.drawBox(context, x + PADDING, itemY, width - (PADDING * 2), ITEM_HEIGHT - 2, fill, border, uiConfig); - context.drawText(textRenderer, item.label(), x + PADDING + ITEM_PADDING_X, itemY + 6, item.active() ? theme.primaryTextColor() : theme.secondaryTextColor(), false); + context.drawString(textRenderer, item.label(), x + PADDING + ITEM_PADDING_X, itemY + 6, item.active() ? theme.primaryTextColor() : theme.secondaryTextColor(), false); } } @@ -95,11 +94,11 @@ public void render(DrawContext context, TextRenderer textRenderer) { return anchorScreenY; } - private void open(double anchorScreenX, double anchorScreenY, GraphEditorBounds bounds, TextRenderer textRenderer, List items) { + private void open(double anchorScreenX, double anchorScreenY, GraphEditorBounds bounds, Font textRenderer, List items) { this.anchorScreenX = anchorScreenX; this.anchorScreenY = anchorScreenY; this.items = List.copyOf(items); - this.width = Math.max(MIN_WIDTH, this.items.stream().mapToInt(item -> textRenderer.getWidth(item.label()) + (ITEM_PADDING_X * 2) + (PADDING * 2)).max().orElse(MIN_WIDTH)); + this.width = Math.max(MIN_WIDTH, this.items.stream().mapToInt(item -> textRenderer.width(item.label()) + (ITEM_PADDING_X * 2) + (PADDING * 2)).max().orElse(MIN_WIDTH)); this.height = (this.items.size() * ITEM_HEIGHT) + (PADDING * 2); int minX = bounds.x() + SCREEN_MARGIN; int maxX = Math.max(minX, bounds.right() - width - SCREEN_MARGIN); diff --git a/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphCursorManager.java b/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphCursorManager.java index fe27890..68bf254 100644 --- a/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphCursorManager.java +++ b/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphCursorManager.java @@ -3,13 +3,13 @@ import com.sun.jna.Library; import com.sun.jna.Native; import com.sun.jna.Pointer; -import net.minecraft.client.MinecraftClient; import org.lwjgl.glfw.GLFW; import org.lwjgl.glfw.GLFWNativeX11; import java.util.EnumMap; import java.util.List; import java.util.Map; +import net.minecraft.client.Minecraft; final class GraphCursorManager { private static final Map STANDARD_CURSORS = new EnumMap<>(CursorKind.class); @@ -24,12 +24,12 @@ static void apply(CursorKind kind) { return; } - MinecraftClient client = MinecraftClient.getInstance(); - if (client == null || client.getWindow() == null || client.mouse == null || client.mouse.isCursorLocked()) { + Minecraft client = Minecraft.getInstance(); + if (client == null || client.getWindow() == null || client.mouseHandler == null || client.mouseHandler.isMouseGrabbed()) { return; } - long windowHandle = client.getWindow().getHandle(); + long windowHandle = client.getWindow().handle(); if (X11ThemeCursorSupport.apply(windowHandle, nextKind)) { currentKind = nextKind; return; diff --git a/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphEditorComponent.java b/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphEditorComponent.java index 6087fae..6f6b298 100644 --- a/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphEditorComponent.java +++ b/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphEditorComponent.java @@ -2,8 +2,8 @@ import com.github.squi2rel.mcng.core.NodePosition; import com.github.squi2rel.mcng.core.NodeType; -import net.minecraft.client.font.TextRenderer; -import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; import org.lwjgl.glfw.GLFW; public final class GraphEditorComponent { @@ -19,7 +19,7 @@ public final class GraphEditorComponent { private final GraphContextMenuComponent contextMenu; private GraphEditorUiConfig uiConfig; - private TextRenderer textRenderer; + private Font textRenderer; private GraphEditorBounds bounds = new GraphEditorBounds(0, 0, 0, 0); private boolean secondaryPointerDown; private boolean secondaryPointerDragged; @@ -49,7 +49,7 @@ public GraphEditorComponent(GraphEditorSession session, NodePaletteRegistry pale this.contextMenu = new GraphContextMenuComponent(this::uiConfig, session::i18n); } - public void init(TextRenderer textRenderer, GraphEditorBounds bounds) { + public void init(Font textRenderer, GraphEditorBounds bounds) { this.textRenderer = textRenderer; this.bounds = bounds; canvas.init(textRenderer, canvasBounds()); @@ -95,7 +95,7 @@ public GraphEditorBounds bounds() { return bounds; } - public void render(DrawContext context, TextRenderer textRenderer, int mouseX, int mouseY, float delta) { + public void render(GuiGraphics context, Font textRenderer, int mouseX, int mouseY, float delta) { this.textRenderer = textRenderer; canvas.render(context, textRenderer, mouseX, mouseY); renderTopBar(context); @@ -266,23 +266,23 @@ public boolean charTyped(char chr, int modifiers) { return palette.charTyped(chr, modifiers); } - private void renderTopBar(DrawContext context) { + private void renderTopBar(GuiGraphics context) { GraphEditorTheme theme = uiConfig.theme(); EditorStyleRenderer.drawBox(context, bounds.x(), bounds.y(), bounds.width(), TOP_BAR_HEIGHT, theme.panelBackgroundColor(), theme.panelBorderColor(), uiConfig); renderBreadcrumbs(context, theme); } - private void renderBreadcrumbs(DrawContext context, GraphEditorTheme theme) { + private void renderBreadcrumbs(GuiGraphics context, GraphEditorTheme theme) { int x = bounds.x() + TOGGLE_AREA_WIDTH(); int y = bounds.y() + TOP_BAR_PADDING; var breadcrumbs = session.breadcrumbs(); for (int index = 0; index < breadcrumbs.size(); index++) { GraphEditorSession.Breadcrumb breadcrumb = breadcrumbs.get(index); - context.drawText(textRenderer, breadcrumb.label(), x, y, theme.accentColor(), false); - x += textRenderer.getWidth(breadcrumb.label()); + context.drawString(textRenderer, breadcrumb.label(), x, y, theme.accentColor(), false); + x += textRenderer.width(breadcrumb.label()); if (index < breadcrumbs.size() - 1) { - context.drawText(textRenderer, " / ", x, y, theme.secondaryTextColor(), false); - x += textRenderer.getWidth(" / "); + context.drawString(textRenderer, " / ", x, y, theme.secondaryTextColor(), false); + x += textRenderer.width(" / "); } } } @@ -291,11 +291,11 @@ private boolean clickBreadcrumb(double mouseX, double mouseY) { int x = bounds.x() + TOGGLE_AREA_WIDTH(); int y = bounds.y() + TOP_BAR_PADDING + 2; for (GraphEditorSession.Breadcrumb breadcrumb : session.breadcrumbs()) { - int width = textRenderer.getWidth(breadcrumb.label()); + int width = textRenderer.width(breadcrumb.label()); if (mouseX >= x && mouseX <= x + width && mouseY >= y - 2 && mouseY <= y + 10) { return session.exitToBreadcrumb(breadcrumb.definitionId()); } - x += width + textRenderer.getWidth(" / "); + x += width + textRenderer.width(" / "); } return false; } diff --git a/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphInputModifiers.java b/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphInputModifiers.java index a02433d..404b1b6 100644 --- a/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphInputModifiers.java +++ b/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphInputModifiers.java @@ -1,8 +1,8 @@ package com.github.squi2rel.mcng.fabric.client; -import net.minecraft.client.MinecraftClient; -import net.minecraft.client.util.InputUtil; -import net.minecraft.client.util.Window; +import com.mojang.blaze3d.platform.InputConstants; +import com.mojang.blaze3d.platform.Window; +import net.minecraft.client.Minecraft; final class GraphInputModifiers { private GraphInputModifiers() { @@ -10,13 +10,13 @@ private GraphInputModifiers() { static boolean shiftDown() { try { - MinecraftClient client = MinecraftClient.getInstance(); + Minecraft client = Minecraft.getInstance(); if (client == null) { return false; } Window window = client.getWindow(); - return InputUtil.isKeyPressed(window, InputUtil.GLFW_KEY_LEFT_SHIFT) - || InputUtil.isKeyPressed(window, InputUtil.GLFW_KEY_RIGHT_SHIFT); + return InputConstants.isKeyDown(window, InputConstants.KEY_LSHIFT) + || InputConstants.isKeyDown(window, InputConstants.KEY_RSHIFT); } catch (RuntimeException ignored) { return false; } diff --git a/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphInputText.java b/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphInputText.java index 3452dff..13d75b6 100644 --- a/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphInputText.java +++ b/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphInputText.java @@ -1,17 +1,17 @@ package com.github.squi2rel.mcng.fabric.client; -import net.minecraft.client.util.InputUtil; +import com.mojang.blaze3d.platform.InputConstants; final class GraphInputText { private GraphInputText() { } static String key(int keyCode) { - return InputUtil.Type.KEYSYM.createFromCode(keyCode).getLocalizedText().getString(); + return InputConstants.Type.KEYSYM.getOrCreate(keyCode).getDisplayName().getString(); } static String mouse(int button) { - return InputUtil.Type.MOUSE.createFromCode(button).getLocalizedText().getString(); + return InputConstants.Type.MOUSE.getOrCreate(button).getDisplayName().getString(); } static String shortcut(String modifier, int keyCode) { diff --git a/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphTextFieldComponent.java b/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphTextFieldComponent.java index f4804aa..23f6b32 100644 --- a/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphTextFieldComponent.java +++ b/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphTextFieldComponent.java @@ -1,12 +1,12 @@ package com.github.squi2rel.mcng.fabric.client; -import net.minecraft.client.font.TextRenderer; -import net.minecraft.client.gui.DrawContext; import org.lwjgl.glfw.GLFW; import java.util.Objects; import java.util.function.Consumer; import java.util.function.Supplier; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; final class GraphTextFieldComponent { private final Supplier clipboardReader; @@ -64,7 +64,7 @@ void setFocused(boolean focused) { } } - void render(DrawContext context, TextRenderer textRenderer, GraphEditorTheme theme, GraphEditorUiConfig uiConfig) { + void render(GuiGraphics context, Font textRenderer, GraphEditorTheme theme, GraphEditorUiConfig uiConfig) { GraphTextInputRenderer.renderFrame(context, bounds, theme, uiConfig, focused); String placeholder = placeholderSupplier.get(); int scissorLeft = bounds.x() + GraphTextInputRenderer.CONTENT_PADDING_X; @@ -76,8 +76,8 @@ void render(DrawContext context, TextRenderer textRenderer, GraphEditorTheme the if (!state.text().isEmpty()) { GraphTextInputRenderer.renderContent(context, textRenderer, bounds, state, theme, focused); } else { - int baselineY = bounds.y() + Math.max(2, (bounds.height() - textRenderer.fontHeight) / 2); - context.drawText(textRenderer, placeholder, bounds.x() + GraphTextInputRenderer.CONTENT_PADDING_X, baselineY, theme.secondaryTextColor(), false); + int baselineY = bounds.y() + Math.max(2, (bounds.height() - textRenderer.lineHeight) / 2); + context.drawString(textRenderer, placeholder, bounds.x() + GraphTextInputRenderer.CONTENT_PADDING_X, baselineY, theme.secondaryTextColor(), false); if (focused && (System.currentTimeMillis() / 530L) % 2L == 0L) { int cursorX = bounds.x() + GraphTextInputRenderer.CONTENT_PADDING_X; context.fill(cursorX, bounds.y() + GraphTextInputRenderer.CONTENT_PADDING_Y, cursorX + 1, bounds.y() + bounds.height() - GraphTextInputRenderer.CONTENT_PADDING_Y, theme.primaryTextColor()); @@ -88,7 +88,7 @@ void render(DrawContext context, TextRenderer textRenderer, GraphEditorTheme the } } - boolean mouseClicked(double mouseX, double mouseY, int button, TextRenderer textRenderer) { + boolean mouseClicked(double mouseX, double mouseY, int button, Font textRenderer) { if (button != 0) { return false; } @@ -101,7 +101,7 @@ boolean mouseClicked(double mouseX, double mouseY, int button, TextRenderer text return true; } - boolean mouseDragged(double mouseX, int button, TextRenderer textRenderer) { + boolean mouseDragged(double mouseX, int button, Font textRenderer) { if (!focused || button != 0 || !draggingPointer) { return false; } @@ -118,7 +118,7 @@ boolean mouseReleased(int button) { return focused; } - boolean keyPressed(int keyCode, int scanCode, int modifiers, TextRenderer textRenderer) { + boolean keyPressed(int keyCode, int scanCode, int modifiers, Font textRenderer) { if (!focused) { return false; } @@ -185,7 +185,7 @@ boolean keyPressed(int keyCode, int scanCode, int modifiers, TextRenderer textRe return true; } - boolean charTyped(char chr, int modifiers, TextRenderer textRenderer) { + boolean charTyped(char chr, int modifiers, Font textRenderer) { if (!focused) { return false; } @@ -198,7 +198,7 @@ boolean charTyped(char chr, int modifiers, TextRenderer textRenderer) { return true; } - private void handlePointerDown(TextRenderer textRenderer, double screenX, long timeMs) { + private void handlePointerDown(Font textRenderer, double screenX, long timeMs) { int index = indexForScreenX(textRenderer, screenX); if ((timeMs - lastPointerDownAt) <= 250L && Math.abs(index - lastPointerIndex) <= 1) { state.selectWordAt(index); @@ -212,12 +212,12 @@ private void handlePointerDown(TextRenderer textRenderer, double screenX, long t ensureCursorVisible(textRenderer); } - private int indexForScreenX(TextRenderer textRenderer, double screenX) { + private int indexForScreenX(Font textRenderer, double screenX) { double localX = screenX - (bounds.x() + GraphTextInputRenderer.CONTENT_PADDING_X) + state.scrollX(); return state.indexForX(textRenderer, localX); } - private void ensureCursorVisible(TextRenderer textRenderer) { + private void ensureCursorVisible(Font textRenderer) { state.ensureCursorVisible(textRenderer, Math.max(1, bounds.width() - (GraphTextInputRenderer.CONTENT_PADDING_X * 2))); } diff --git a/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphTextInputRenderer.java b/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphTextInputRenderer.java index 89d9397..a8530e9 100644 --- a/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphTextInputRenderer.java +++ b/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphTextInputRenderer.java @@ -1,7 +1,7 @@ package com.github.squi2rel.mcng.fabric.client; -import net.minecraft.client.font.TextRenderer; -import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; final class GraphTextInputRenderer { static final int CONTENT_PADDING_X = 4; @@ -11,8 +11,8 @@ private GraphTextInputRenderer() { } static void render( - DrawContext context, - TextRenderer textRenderer, + GuiGraphics context, + Font textRenderer, NodeWidget.Bounds bounds, GraphTextInputState state, GraphEditorTheme theme, @@ -24,7 +24,7 @@ static void render( } static void renderFrame( - DrawContext context, + GuiGraphics context, NodeWidget.Bounds bounds, GraphEditorTheme theme, GraphEditorUiConfig uiConfig @@ -33,7 +33,7 @@ static void renderFrame( } static void renderFrame( - DrawContext context, + GuiGraphics context, NodeWidget.Bounds bounds, GraphEditorTheme theme, GraphEditorUiConfig uiConfig, @@ -52,8 +52,8 @@ static void renderFrame( } static void renderContent( - DrawContext context, - TextRenderer textRenderer, + GuiGraphics context, + Font textRenderer, NodeWidget.Bounds bounds, GraphTextInputState state, GraphEditorTheme theme, @@ -61,15 +61,15 @@ static void renderContent( ) { int innerX = bounds.x() + CONTENT_PADDING_X; int innerWidth = Math.max(1, bounds.width() - (CONTENT_PADDING_X * 2)); - int baselineY = bounds.y() + Math.max(2, (bounds.height() - textRenderer.fontHeight) / 2); + int baselineY = bounds.y() + Math.max(2, (bounds.height() - textRenderer.lineHeight) / 2); String text = state.text(); VisibleTextSlice visible = visibleSlice(textRenderer, state, innerWidth); - int prefixWidth = textRenderer.getWidth(text.substring(0, visible.start())); + int prefixWidth = textRenderer.width(text.substring(0, visible.start())); int textX = innerX + prefixWidth - state.scrollX(); if (focused && state.hasSelection()) { - int selectionStartX = innerX + textRenderer.getWidth(text.substring(0, state.selectionStart())) - state.scrollX(); - int selectionWidth = textRenderer.getWidth(text.substring(state.selectionStart(), state.selectionEnd())); + int selectionStartX = innerX + textRenderer.width(text.substring(0, state.selectionStart())) - state.scrollX(); + int selectionWidth = textRenderer.width(text.substring(state.selectionStart(), state.selectionEnd())); context.fill( selectionStartX, bounds.y() + CONTENT_PADDING_Y, @@ -79,14 +79,14 @@ static void renderContent( ); } - context.drawText(textRenderer, visible.text(), textX, baselineY, theme.primaryTextColor(), false); + context.drawString(textRenderer, visible.text(), textX, baselineY, theme.primaryTextColor(), false); if (focused && (System.currentTimeMillis() / 530L) % 2L == 0L) { - int cursorX = innerX + textRenderer.getWidth(text.substring(0, state.cursor())) - state.scrollX(); + int cursorX = innerX + textRenderer.width(text.substring(0, state.cursor())) - state.scrollX(); context.fill(cursorX, bounds.y() + CONTENT_PADDING_Y, cursorX + 1, bounds.y() + bounds.height() - CONTENT_PADDING_Y, theme.primaryTextColor()); } } - private static VisibleTextSlice visibleSlice(TextRenderer textRenderer, GraphTextInputState state, int innerWidth) { + private static VisibleTextSlice visibleSlice(Font textRenderer, GraphTextInputState state, int innerWidth) { String text = state.text(); if (text.isEmpty()) { return new VisibleTextSlice(0, 0, ""); @@ -94,7 +94,7 @@ private static VisibleTextSlice visibleSlice(TextRenderer textRenderer, GraphTex int start = 0; while (start < text.length()) { - int nextWidth = textRenderer.getWidth(text.substring(0, start + 1)); + int nextWidth = textRenderer.width(text.substring(0, start + 1)); if (nextWidth > state.scrollX()) { break; } @@ -104,7 +104,7 @@ private static VisibleTextSlice visibleSlice(TextRenderer textRenderer, GraphTex int end = start; int visibleRight = state.scrollX() + innerWidth; while (end < text.length()) { - int nextWidth = textRenderer.getWidth(text.substring(0, end + 1)); + int nextWidth = textRenderer.width(text.substring(0, end + 1)); if (nextWidth > visibleRight) { break; } diff --git a/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphTextInputState.java b/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphTextInputState.java index 54e8b0e..94bd9ee 100644 --- a/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphTextInputState.java +++ b/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/GraphTextInputState.java @@ -1,9 +1,8 @@ package com.github.squi2rel.mcng.fabric.client; -import net.minecraft.client.font.TextRenderer; - import java.util.ArrayDeque; import java.util.Deque; +import net.minecraft.client.gui.Font; final class GraphTextInputState { private static final int MAX_LENGTH = 128; @@ -173,13 +172,13 @@ void selectWordAt(int index) { selectRange(start, end); } - int indexForX(TextRenderer textRenderer, double x) { + int indexForX(Font textRenderer, double x) { if (x <= 0) { return 0; } int previousWidth = 0; for (int index = 1; index <= text.length(); index++) { - int width = textRenderer.getWidth(text.substring(0, index)); + int width = textRenderer.width(text.substring(0, index)); if (x < width) { return x - previousWidth < width - x ? index - 1 : index; } @@ -188,10 +187,10 @@ int indexForX(TextRenderer textRenderer, double x) { return text.length(); } - void ensureCursorVisible(TextRenderer textRenderer, int innerWidth) { + void ensureCursorVisible(Font textRenderer, int innerWidth) { int clampedInnerWidth = Math.max(1, innerWidth); - int cursorX = textRenderer.getWidth(text.substring(0, cursor)); - int maxScroll = Math.max(0, textRenderer.getWidth(text) - clampedInnerWidth); + int cursorX = textRenderer.width(text.substring(0, cursor)); + int maxScroll = Math.max(0, textRenderer.width(text) - clampedInnerWidth); if (cursorX < scrollX) { scrollX = cursorX; } else if (cursorX > scrollX + clampedInnerWidth - 1) { diff --git a/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/ImagePreviewNodeBodyComponent.java b/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/ImagePreviewNodeBodyComponent.java index 755e8bb..6a5f219 100644 --- a/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/ImagePreviewNodeBodyComponent.java +++ b/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/ImagePreviewNodeBodyComponent.java @@ -1,14 +1,7 @@ package com.github.squi2rel.mcng.fabric.client; import com.google.gson.JsonObject; -import net.minecraft.client.MinecraftClient; -import net.minecraft.client.font.TextRenderer; -import net.minecraft.client.gl.RenderPipelines; -import net.minecraft.client.gui.DrawContext; -import net.minecraft.client.texture.NativeImage; -import net.minecraft.client.texture.NativeImageBackedTexture; -import net.minecraft.util.Identifier; - +import com.mojang.blaze3d.platform.NativeImage; import java.io.IOException; import java.io.InputStream; import java.nio.file.InvalidPathException; @@ -17,6 +10,12 @@ import java.util.List; import java.util.Objects; import java.util.concurrent.atomic.AtomicInteger; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.client.renderer.texture.DynamicTexture; +import net.minecraft.resources.Identifier; final class ImagePreviewNodeBodyComponent implements NodeBodyComponent { private static final AtomicInteger NEXT_TEXTURE_ID = new AtomicInteger(); @@ -34,7 +33,7 @@ final class ImagePreviewNodeBodyComponent implements NodeBodyComponent { private String loadedPath = null; private LoadError loadError; private Identifier textureId; - private NativeImageBackedTexture texture; + private DynamicTexture texture; private int imageWidth; private int imageHeight; @@ -88,7 +87,7 @@ public void close() { } private void renderPreviewArea(NodeBodyRenderContext context, NodeWidget.Bounds bounds) { - DrawContext drawContext = context.drawContext(); + GuiGraphics drawContext = context.drawContext(); GraphEditorTheme theme = context.theme(); int fill = EditorStyleRenderer.darken(theme.nodeBodyColor(), 0.06f); int border = loadError == null ? theme.panelBorderColor() : theme.errorColor(); @@ -102,11 +101,11 @@ private void renderPreviewArea(NodeBodyRenderContext context, NodeWidget.Bounds if (textureId != null && imageWidth > 0 && imageHeight > 0) { drawTexture(drawContext, bounds); String info = imageWidth + " x " + imageHeight; - int infoWidth = context.textRenderer().getWidth(info); + int infoWidth = context.textRenderer().width(info); int infoX = bounds.x() + Math.max(PADDING, bounds.width() - infoWidth - PADDING); - int infoY = bounds.y() + Math.max(PADDING, bounds.height() - context.textRenderer().fontHeight - PADDING); - drawContext.fill(infoX - 3, infoY - 1, infoX + infoWidth + 3, infoY + context.textRenderer().fontHeight + 1, 0x99000000); - drawContext.drawText(context.textRenderer(), info, infoX, infoY, theme.primaryTextColor(), false); + int infoY = bounds.y() + Math.max(PADDING, bounds.height() - context.textRenderer().lineHeight - PADDING); + drawContext.fill(infoX - 3, infoY - 1, infoX + infoWidth + 3, infoY + context.textRenderer().lineHeight + 1, 0x99000000); + drawContext.drawString(context.textRenderer(), info, infoX, infoY, theme.primaryTextColor(), false); return; } @@ -123,8 +122,8 @@ private void renderPathField(NodeBodyRenderContext context, NodeWidget.Bounds bo ? context.translate("mcng.ui.image_preview.no_file", "No file selected") : trimLeading(context.textRenderer(), filePath, Math.max(1, bounds.width() - (GraphTextInputRenderer.CONTENT_PADDING_X * 2))); int color = filePath.isBlank() ? context.theme().secondaryTextColor() : context.theme().primaryTextColor(); - int baselineY = bounds.y() + Math.max(2, (bounds.height() - context.textRenderer().fontHeight) / 2); - context.drawContext().drawText(context.textRenderer(), display, bounds.x() + GraphTextInputRenderer.CONTENT_PADDING_X, baselineY, color, false); + int baselineY = bounds.y() + Math.max(2, (bounds.height() - context.textRenderer().lineHeight) / 2); + context.drawContext().drawString(context.textRenderer(), display, bounds.x() + GraphTextInputRenderer.CONTENT_PADDING_X, baselineY, color, false); } private void renderButtons(NodeBodyRenderContext context, Layout layout, String filePath) { @@ -141,12 +140,12 @@ private void renderButton(NodeBodyRenderContext context, NodeWidget.Bounds bound int border = enabled ? accentColor : theme.panelBorderColor(); int textColor = enabled ? theme.primaryTextColor() : theme.secondaryTextColor(); EditorStyleRenderer.drawBox(context.drawContext(), bounds.x(), bounds.y(), bounds.width(), bounds.height(), fill, border, context.uiConfig()); - int textX = bounds.x() + Math.max(4, (bounds.width() - context.textRenderer().getWidth(label)) / 2); - int textY = bounds.y() + Math.max(2, (bounds.height() - context.textRenderer().fontHeight) / 2); - context.drawContext().drawText(context.textRenderer(), label, textX, textY, textColor, false); + int textX = bounds.x() + Math.max(4, (bounds.width() - context.textRenderer().width(label)) / 2); + int textY = bounds.y() + Math.max(2, (bounds.height() - context.textRenderer().lineHeight) / 2); + context.drawContext().drawString(context.textRenderer(), label, textX, textY, textColor, false); } - private void drawTexture(DrawContext context, NodeWidget.Bounds bounds) { + private void drawTexture(GuiGraphics context, NodeWidget.Bounds bounds) { int availableWidth = Math.max(1, bounds.width() - (PADDING * 2)); int availableHeight = Math.max(1, bounds.height() - (PADDING * 2)); double scale = Math.min(availableWidth / (double) imageWidth, availableHeight / (double) imageHeight); @@ -154,14 +153,14 @@ private void drawTexture(DrawContext context, NodeWidget.Bounds bounds) { int drawHeight = Math.max(1, (int) Math.round(imageHeight * scale)); int drawX = bounds.x() + ((bounds.width() - drawWidth) / 2); int drawY = bounds.y() + ((bounds.height() - drawHeight) / 2); - context.drawTexture(RenderPipelines.GUI_TEXTURED, textureId, drawX, drawY, 0.0f, 0.0f, drawWidth, drawHeight, imageWidth, imageHeight, imageWidth, imageHeight); + context.blit(RenderPipelines.GUI_TEXTURED, textureId, drawX, drawY, 0.0f, 0.0f, drawWidth, drawHeight, imageWidth, imageHeight, imageWidth, imageHeight); } - private void drawCenteredLabel(DrawContext context, TextRenderer textRenderer, NodeWidget.Bounds bounds, String label, int color) { + private void drawCenteredLabel(GuiGraphics context, Font textRenderer, NodeWidget.Bounds bounds, String label, int color) { String text = trimCenter(textRenderer, label, Math.max(1, bounds.width() - (PADDING * 2))); - int x = bounds.x() + Math.max(PADDING, (bounds.width() - textRenderer.getWidth(text)) / 2); - int y = bounds.y() + Math.max(PADDING, (bounds.height() - textRenderer.fontHeight) / 2); - context.drawText(textRenderer, text, x, y, color, false); + int x = bounds.x() + Math.max(PADDING, (bounds.width() - textRenderer.width(text)) / 2); + int y = bounds.y() + Math.max(PADDING, (bounds.height() - textRenderer.lineHeight) / 2); + context.drawString(textRenderer, text, x, y, color, false); } private void syncTexture(String filePath) { @@ -186,15 +185,15 @@ private void syncTexture(String filePath) { try (InputStream stream = Files.newInputStream(path)) { NativeImage image = NativeImage.read(stream); - Identifier id = Identifier.of("mcng", "image_preview/" + NEXT_TEXTURE_ID.incrementAndGet()); - NativeImageBackedTexture loadedTexture = new NativeImageBackedTexture(id::toString, image); - MinecraftClient client = MinecraftClient.getInstance(); + Identifier id = Identifier.fromNamespaceAndPath("mcng", "image_preview/" + NEXT_TEXTURE_ID.incrementAndGet()); + DynamicTexture loadedTexture = new DynamicTexture(id::toString, image); + Minecraft client = Minecraft.getInstance(); if (client == null) { loadedTexture.close(); loadError = LoadError.CLIENT_UNAVAILABLE; return; } - client.getTextureManager().registerTexture(id, loadedTexture); + client.getTextureManager().register(id, loadedTexture); textureId = id; texture = loadedTexture; imageWidth = image.getWidth(); @@ -207,9 +206,9 @@ private void syncTexture(String filePath) { private void releaseTexture() { if (textureId != null) { - MinecraftClient client = MinecraftClient.getInstance(); + Minecraft client = Minecraft.getInstance(); if (client != null) { - client.getTextureManager().destroyTexture(textureId); + client.getTextureManager().release(textureId); } else if (texture != null) { texture.close(); } @@ -236,33 +235,33 @@ private static String filePath(JsonObject config) { return config.get(FILE_PATH_KEY).getAsString(); } - private static String trimLeading(TextRenderer textRenderer, String text, int maxWidth) { - if (textRenderer.getWidth(text) <= maxWidth) { + private static String trimLeading(Font textRenderer, String text, int maxWidth) { + if (textRenderer.width(text) <= maxWidth) { return text; } String ellipsis = "..."; - int ellipsisWidth = textRenderer.getWidth(ellipsis); + int ellipsisWidth = textRenderer.width(ellipsis); if (ellipsisWidth >= maxWidth) { return ellipsis; } String value = text; - while (!value.isEmpty() && textRenderer.getWidth(value) + ellipsisWidth > maxWidth) { + while (!value.isEmpty() && textRenderer.width(value) + ellipsisWidth > maxWidth) { value = value.substring(1); } return ellipsis + value; } - private static String trimCenter(TextRenderer textRenderer, String text, int maxWidth) { - if (textRenderer.getWidth(text) <= maxWidth) { + private static String trimCenter(Font textRenderer, String text, int maxWidth) { + if (textRenderer.width(text) <= maxWidth) { return text; } String ellipsis = "..."; - int ellipsisWidth = textRenderer.getWidth(ellipsis); + int ellipsisWidth = textRenderer.width(ellipsis); if (ellipsisWidth >= maxWidth) { return ellipsis; } String value = text; - while (!value.isEmpty() && textRenderer.getWidth(value) + ellipsisWidth > maxWidth) { + while (!value.isEmpty() && textRenderer.width(value) + ellipsisWidth > maxWidth) { value = value.substring(0, value.length() - 1); } return value + ellipsis; diff --git a/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/MCNGDebugScreen.java b/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/MCNGDebugScreen.java index 3a9bb8d..7cfb39c 100644 --- a/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/MCNGDebugScreen.java +++ b/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/MCNGDebugScreen.java @@ -7,15 +7,6 @@ import com.github.squi2rel.mcng.core.GraphVariableDefinition; import com.github.squi2rel.mcng.core.NodeTypeRegistry; import com.github.squi2rel.mcng.core.PortTypeRegistry; -import net.minecraft.client.MinecraftClient; -import net.minecraft.client.font.TextRenderer; -import net.minecraft.client.resource.language.I18n; -import net.minecraft.client.gui.Click; -import net.minecraft.client.gui.DrawContext; -import net.minecraft.client.gui.screen.Screen; -import net.minecraft.client.input.CharInput; -import net.minecraft.client.input.KeyInput; -import net.minecraft.text.Text; import org.lwjgl.PointerBuffer; import org.lwjgl.glfw.GLFW; import org.lwjgl.system.MemoryStack; @@ -25,6 +16,15 @@ import java.util.List; import java.util.Optional; import java.util.function.Consumer; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; +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.network.chat.Component; public final class MCNGDebugScreen extends Screen implements GraphEditorHost { private static final int EXECUTION_STEP_BUDGET = 8; @@ -41,7 +41,7 @@ public final class MCNGDebugScreen extends Screen implements GraphEditorHost { private static final int DEBUG_VARIABLE_ROW_STEP = 18; private static final int DEBUG_VARIABLE_ROWS_VISIBLE = 5; private static final GraphEditorI18n MINECRAFT_I18N = (key, fallback, args) -> - I18n.hasTranslation(key) ? I18n.translate(key, args) : GraphEditorI18n.formatFallback(fallback, key, args); + I18n.exists(key) ? I18n.get(key, args) : GraphEditorI18n.formatFallback(fallback, key, args); private static final List THEME_OPTIONS = List.of( new ThemeOption("classic", "Classic", GraphEditorTheme.classic()), new ThemeOption("light", "Light", GraphEditorTheme.light()), @@ -87,7 +87,7 @@ public MCNGDebugScreen( Consumer onPersist, Consumer statusSink ) { - super(minecraftClient(), minecraftTextRenderer(), Text.translatable("mcng.ui.debug.screen_title")); + super(minecraftClient(), minecraftTextRenderer(), Component.translatable("mcng.ui.debug.screen_title")); this.registry = registry; this.portTypes = portTypes; this.paletteRegistry = paletteRegistry; @@ -101,19 +101,19 @@ public MCNGDebugScreen( this.statusMessage = translate("mcng.ui.debug.command_hint", "/mcng editor"); } - private static MinecraftClient minecraftClient() { - return MinecraftClient.getInstance(); + private static Minecraft minecraftClient() { + return Minecraft.getInstance(); } - private static TextRenderer minecraftTextRenderer() { - MinecraftClient client = MinecraftClient.getInstance(); - return client == null ? null : client.textRenderer; + private static Font minecraftTextRenderer() { + Minecraft client = Minecraft.getInstance(); + return client == null ? null : client.font; } @Override protected void init() { super.init(); - editor.init(textRenderer, new GraphEditorBounds(0, 0, width, height)); + editor.init(font, new GraphEditorBounds(0, 0, width, height)); } @Override @@ -123,14 +123,14 @@ public void tick() { } @Override - public void render(DrawContext context, int mouseX, int mouseY, float delta) { + public void render(GuiGraphics context, int mouseX, int mouseY, float delta) { editor.setBounds(new GraphEditorBounds(0, 0, width, height)); - editor.render(context, textRenderer, mouseX, mouseY, delta); + editor.render(context, font, mouseX, mouseY, delta); renderOverlay(context); } @Override - public boolean mouseClicked(Click click, boolean doubleClick) { + public boolean mouseClicked(MouseButtonEvent click, boolean doubleClick) { return mouseClicked(click.x(), click.y(), click.button()) || super.mouseClicked(click, doubleClick); } @@ -148,7 +148,7 @@ public boolean mouseClicked(double mouseX, double mouseY, int button) { } @Override - public boolean mouseDragged(Click click, double deltaX, double deltaY) { + public boolean mouseDragged(MouseButtonEvent click, double deltaX, double deltaY) { return mouseDragged(click.x(), click.y(), click.button(), deltaX, deltaY) || super.mouseDragged(click, deltaX, deltaY); } @@ -157,7 +157,7 @@ public boolean mouseDragged(double mouseX, double mouseY, int button, double del } @Override - public boolean mouseReleased(Click click) { + public boolean mouseReleased(MouseButtonEvent click) { return mouseReleased(click.x(), click.y(), click.button()) || super.mouseReleased(click); } @@ -171,7 +171,7 @@ public boolean mouseScrolled(double mouseX, double mouseY, double horizontalAmou } @Override - public boolean keyPressed(KeyInput input) { + public boolean keyPressed(KeyEvent input) { return keyPressed(input.key(), input.scancode(), input.modifiers()) || super.keyPressed(input); } @@ -206,9 +206,9 @@ public boolean keyPressed(int keyCode, int scanCode, int modifiers) { } @Override - public boolean charTyped(CharInput input) { - if (input.isValidChar()) { - String text = input.asString(); + public boolean charTyped(CharacterEvent input) { + if (input.isAllowedChatCharacter()) { + String text = input.codepointAsString(); if (text.length() == 1 && charTyped(text.charAt(0), input.modifiers())) { return true; } @@ -224,10 +224,10 @@ public boolean charTyped(char chr, int modifiers) { } @Override - public void close() { + public void onClose() { onPersist.accept(session.document()); editor.close(); - super.close(); + super.onClose(); } @Override @@ -237,14 +237,14 @@ public void onDocumentChanged(GraphDocument document) { @Override public void copyToClipboard(String value) { - if (client != null) { - client.keyboard.setClipboard(value); + if (minecraft != null) { + minecraft.keyboardHandler.setClipboard(value); } } @Override public String readClipboard() { - return client != null ? client.keyboard.getClipboard() : ""; + return minecraft != null ? minecraft.keyboardHandler.getClipboard() : ""; } @Override @@ -282,7 +282,7 @@ public Optional chooseFile(GraphFileDialogRequest request) { } } - private void renderOverlay(DrawContext context) { + private void renderOverlay(GuiGraphics context) { GraphEditorUiConfig uiConfig = editor.uiConfig(); GraphEditorTheme theme = uiConfig.theme(); List help = helpLines(); @@ -292,14 +292,14 @@ private void renderOverlay(DrawContext context) { int x = editor.isPaletteOpen() ? editor.paletteSidebarRight() + 10 : 10; int y = 36; EditorStyleRenderer.drawBox(context, x, y, panelWidth, panelHeight, theme.panelBackgroundColor(), theme.panelBorderColor(), uiConfig); - context.drawText(textRenderer, title, x + 8, y + 8, theme.primaryTextColor(), false); + context.drawString(font, title, x + 8, y + 8, theme.primaryTextColor(), false); for (int index = 0; index < help.size(); index++) { - context.drawText(textRenderer, help.get(index), x + 8, y + 24 + (index * 12), theme.secondaryTextColor(), false); + context.drawString(font, help.get(index), x + 8, y + 24 + (index * 12), theme.secondaryTextColor(), false); } int statusY = y + panelHeight + 6; EditorStyleRenderer.drawBox(context, x, statusY, panelWidth, 16, theme.panelBackgroundColor(), theme.panelBorderColor(), uiConfig); - context.drawText(textRenderer, translate("mcng.ui.debug.status", "Status: %s", statusMessage), x + 8, statusY + 4, theme.accentColor(), false); + context.drawString(font, translate("mcng.ui.debug.status", "Status: %s", statusMessage), x + 8, statusY + 4, theme.accentColor(), false); if (!debugPanelVisible) { return; @@ -307,20 +307,20 @@ private void renderOverlay(DrawContext context) { DebugPanelLayout layout = debugPanelLayout(); EditorStyleRenderer.drawBox(context, layout.x(), layout.y(), layout.width(), layout.height(), theme.panelBackgroundColor(), theme.panelBorderColor(), uiConfig); - context.drawText(textRenderer, translate("mcng.ui.debug.panel_title", "Debug"), layout.x() + 8, layout.y() + 8, theme.primaryTextColor(), false); - context.drawText(textRenderer, session.isExecutionRunning() ? translate("mcng.ui.debug.running", "Running") : translate("mcng.ui.debug.idle", "Idle"), layout.x() + layout.width() - 46, layout.y() + 8, session.isExecutionRunning() ? theme.executionColor() : theme.secondaryTextColor(), false); + context.drawString(font, translate("mcng.ui.debug.panel_title", "Debug"), layout.x() + 8, layout.y() + 8, theme.primaryTextColor(), false); + context.drawString(font, session.isExecutionRunning() ? translate("mcng.ui.debug.running", "Running") : translate("mcng.ui.debug.idle", "Idle"), layout.x() + layout.width() - 46, layout.y() + 8, session.isExecutionRunning() ? theme.executionColor() : theme.secondaryTextColor(), false); List debug = session.debugMessages(); for (int index = 0; index < Math.min(debug.size(), 2); index++) { - context.drawText(textRenderer, debug.get(debug.size() - 1 - index), layout.x() + 8, layout.y() + 24 + (index * 12), theme.secondaryTextColor(), false); + context.drawString(font, debug.get(debug.size() - 1 - index), layout.x() + 8, layout.y() + 24 + (index * 12), theme.secondaryTextColor(), false); } List errors = session.lastErrors(); for (int index = 0; index < Math.min(errors.size(), 2); index++) { - context.drawText(textRenderer, GraphEditorTranslations.formatError(i18n(), errors.get(index)), layout.x() + 8, layout.y() + 50 + (index * 10), theme.errorColor(), false); + context.drawString(font, GraphEditorTranslations.formatError(i18n(), errors.get(index)), layout.x() + 8, layout.y() + 50 + (index * 10), theme.errorColor(), false); } - context.drawText(textRenderer, translate("mcng.ui.debug.section.editor", "Editor"), layout.x() + DEBUG_PANEL_PADDING, settingsTitleY(layout), theme.primaryTextColor(), false); + context.drawString(font, translate("mcng.ui.debug.section.editor", "Editor"), layout.x() + DEBUG_PANEL_PADDING, settingsTitleY(layout), theme.primaryTextColor(), false); for (DebugButton button : debugButtons(layout)) { int fill = button.active() @@ -328,16 +328,16 @@ private void renderOverlay(DrawContext context) { : theme.nodeBodyColor(); int border = button.active() ? theme.accentColor() : theme.panelBorderColor(); EditorStyleRenderer.drawBox(context, button.x(), button.y(), button.width(), button.height(), fill, border, uiConfig); - context.drawText(textRenderer, button.label(), button.x() + 6, button.y() + 5, theme.primaryTextColor(), false); + context.drawString(font, button.label(), button.x() + 6, button.y() + 5, theme.primaryTextColor(), false); } - context.drawText(textRenderer, translate("mcng.ui.debug.section.variables", "Variables"), layout.x() + DEBUG_PANEL_PADDING, variablesTitleY(layout), theme.primaryTextColor(), false); + context.drawString(font, translate("mcng.ui.debug.section.variables", "Variables"), layout.x() + DEBUG_PANEL_PADDING, variablesTitleY(layout), theme.primaryTextColor(), false); for (VariableRow row : variableRows(layout)) { int fill = row.selected() ? EditorStyleRenderer.blend(theme.nodeBodyColor(), theme.accentColor(), 0.2f) : EditorStyleRenderer.darken(theme.nodeBodyColor(), 0.02f); EditorStyleRenderer.drawBox(context, row.x(), row.y(), row.width(), row.height(), fill, row.selected() ? theme.accentColor() : theme.panelBorderColor(), uiConfig); - context.drawText(textRenderer, row.label(), row.x() + 6, row.y() + 5, theme.secondaryTextColor(), false); + context.drawString(font, row.label(), row.x() + 6, row.y() + 5, theme.secondaryTextColor(), false); } } diff --git a/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeBodyRenderContext.java b/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeBodyRenderContext.java index 803a032..45e994a 100644 --- a/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeBodyRenderContext.java +++ b/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeBodyRenderContext.java @@ -3,15 +3,14 @@ import com.github.squi2rel.mcng.core.NodeInstance; import com.github.squi2rel.mcng.core.NodeType; import com.google.gson.JsonObject; -import net.minecraft.client.font.TextRenderer; -import net.minecraft.client.gui.DrawContext; - import java.util.Objects; import java.util.Optional; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; public record NodeBodyRenderContext( - DrawContext drawContext, - TextRenderer textRenderer, + GuiGraphics drawContext, + Font textRenderer, NodeWidget.Bounds bounds, NodeInstance node, NodeType nodeType, diff --git a/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/NodePaletteComponent.java b/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/NodePaletteComponent.java index 7a27d25..a07d46a 100644 --- a/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/NodePaletteComponent.java +++ b/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/NodePaletteComponent.java @@ -4,14 +4,14 @@ import com.github.squi2rel.mcng.core.PortChannel; import com.github.squi2rel.mcng.core.NodeTypeRegistry; import com.github.squi2rel.mcng.core.PortTypeRegistry; -import net.minecraft.client.font.TextRenderer; -import net.minecraft.client.gui.DrawContext; import org.lwjgl.glfw.GLFW; import java.util.ArrayList; import java.util.List; import java.util.function.Consumer; import java.util.function.Supplier; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; public final class NodePaletteComponent { private static final NodeComponentRegistry EMPTY_COMPONENT_REGISTRY = new NodeComponentRegistry(); @@ -41,7 +41,7 @@ public final class NodePaletteComponent { private final GraphTextFieldComponent searchField; private GraphEditorBounds bounds = new GraphEditorBounds(0, 0, 0, 0); - private TextRenderer textRenderer; + private Font textRenderer; public NodePaletteComponent( Supplier> sectionsSupplier, @@ -79,7 +79,7 @@ public NodePaletteComponent( ); } - public void init(TextRenderer textRenderer, GraphEditorBounds bounds) { + public void init(Font textRenderer, GraphEditorBounds bounds) { this.textRenderer = textRenderer; setBounds(bounds); searchField.setText(state.query()); @@ -128,7 +128,7 @@ public GraphCursorManager.CursorKind cursorKindAt(double mouseX, double mouseY) return GraphCursorManager.CursorKind.DEFAULT; } - public void render(DrawContext context, TextRenderer textRenderer, int mouseX, int mouseY, float delta) { + public void render(GuiGraphics context, Font textRenderer, int mouseX, int mouseY, float delta) { this.textRenderer = textRenderer; GraphEditorUiConfig uiConfig = uiConfigSupplier.get(); GraphEditorTheme theme = uiConfig.theme(); @@ -139,7 +139,7 @@ public void render(DrawContext context, TextRenderer textRenderer, int mouseX, i } EditorStyleRenderer.drawBox(context, panelX(), panelY(), PANEL_WIDTH, panelBottom() - panelY(), theme.panelBackgroundColor(), theme.panelBorderColor(), uiConfig); - context.drawText(textRenderer, GraphEditorTranslations.ui(i18n, "palette.title", "Nodes"), panelX() + PANEL_PADDING, panelY() + 10, theme.primaryTextColor(), false); + context.drawString(textRenderer, GraphEditorTranslations.ui(i18n, "palette.title", "Nodes"), panelX() + PANEL_PADDING, panelY() + 10, theme.primaryTextColor(), false); searchField.render(context, textRenderer, theme, uiConfig); int listTop = panelY() + 28 + SEARCH_HEIGHT + LIST_TOP_GAP; @@ -154,7 +154,7 @@ public void render(DrawContext context, TextRenderer textRenderer, int mouseX, i for (Row row : rows) { if (row.type() == RowType.SECTION) { if (y + SECTION_HEADER_HEIGHT >= listTop && y <= listBottom) { - context.drawText(textRenderer, row.title(), panelX() + PANEL_PADDING, y + 4, theme.accentColor(), false); + context.drawString(textRenderer, row.title(), panelX() + PANEL_PADDING, y + 4, theme.accentColor(), false); } y += SECTION_HEADER_HEIGHT; continue; @@ -167,8 +167,8 @@ public void render(DrawContext context, TextRenderer textRenderer, int mouseX, i ? EditorStyleRenderer.blend(theme.nodeBodyColor(), theme.accentColor(), 0.24f) : hovered ? EditorStyleRenderer.brighten(theme.nodeBodyColor(), 0.08f) : theme.nodeBodyColor(); EditorStyleRenderer.drawBox(context, panelX() + PANEL_PADDING, y, PANEL_WIDTH - (PANEL_PADDING * 2), ENTRY_HEIGHT, fill, theme.panelBorderColor(), uiConfig); - context.drawText(textRenderer, row.entry().displayName(), panelX() + PANEL_PADDING + 6, y + 5, theme.primaryTextColor(), false); - context.drawText(textRenderer, row.entry().subtitle(), panelX() + PANEL_PADDING + 6, y + 14, theme.secondaryTextColor(), false); + context.drawString(textRenderer, row.entry().displayName(), panelX() + PANEL_PADDING + 6, y + 5, theme.primaryTextColor(), false); + context.drawString(textRenderer, row.entry().subtitle(), panelX() + PANEL_PADDING + 6, y + 14, theme.secondaryTextColor(), false); } y += ENTRY_HEIGHT; } @@ -288,7 +288,7 @@ public boolean charTyped(char chr, int modifiers) { return state.open() && searchField.charTyped(chr, modifiers, textRenderer); } - public void renderDragPreview(DrawContext context, TextRenderer textRenderer) { + public void renderDragPreview(GuiGraphics context, Font textRenderer) { if (!state.dragging() || state.dragEntry() == null) { return; } @@ -321,10 +321,10 @@ public void renderDragPreview(DrawContext context, TextRenderer textRenderer) { } } - private void renderToggleButton(DrawContext context, TextRenderer textRenderer, GraphEditorUiConfig uiConfig, GraphEditorTheme theme) { + private void renderToggleButton(GuiGraphics context, Font textRenderer, GraphEditorUiConfig uiConfig, GraphEditorTheme theme) { int fill = state.open() ? EditorStyleRenderer.blend(theme.panelBackgroundColor(), theme.accentColor(), 0.18f) : theme.panelBackgroundColor(); EditorStyleRenderer.drawBox(context, toggleX(), toggleY(), TOGGLE_WIDTH, TOGGLE_HEIGHT, fill, theme.panelBorderColor(), uiConfig); - context.drawText( + context.drawString( textRenderer, state.open() ? GraphEditorTranslations.ui(i18nSupplier.get(), "palette.toggle_open", "Nodes [%s]", GraphInputText.key(GLFW.GLFW_KEY_TAB)) @@ -336,7 +336,7 @@ private void renderToggleButton(DrawContext context, TextRenderer textRenderer, ); } - private void renderScrollbar(DrawContext context, List rows, int listTop, int listBottom, GraphEditorUiConfig uiConfig, GraphEditorTheme theme) { + private void renderScrollbar(GuiGraphics context, List rows, int listTop, int listBottom, GraphEditorUiConfig uiConfig, GraphEditorTheme theme) { int viewportHeight = listBottom - listTop; int contentHeight = totalContentHeight(rows); if (contentHeight <= viewportHeight) { diff --git a/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeWidgetRenderer.java b/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeWidgetRenderer.java index fe7f04f..0ae4c4c 100644 --- a/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeWidgetRenderer.java +++ b/mcng-fabric-client/src/main/java/com/github/squi2rel/mcng/fabric/client/NodeWidgetRenderer.java @@ -6,20 +6,19 @@ import com.github.squi2rel.mcng.core.NodeVisualStyle; import com.github.squi2rel.mcng.core.NumericTypes; import com.github.squi2rel.mcng.core.PortId; -import net.minecraft.client.font.TextRenderer; -import net.minecraft.client.gui.DrawContext; - import java.util.function.BiPredicate; import java.util.function.Function; import java.util.function.Predicate; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; final class NodeWidgetRenderer { private NodeWidgetRenderer() { } static void render( - DrawContext context, - TextRenderer textRenderer, + GuiGraphics context, + Font textRenderer, NodeWidget widget, GraphEditorUiConfig uiConfig, GraphEditorTheme theme, @@ -48,8 +47,8 @@ static void render( } if (!compactReroute && widget.showHeaderTitle() && detailLevel != NodeRenderDetailLevel.MINIMAL) { - int titleY = widget.y() + Math.max(2, (widget.headerHeight() - textRenderer.fontHeight) / 2); - context.drawText(textRenderer, GraphEditorTranslations.nodeTitle(widget.i18n(), widget.nodeType()), widget.x() + widget.edgePadding(), titleY, theme.primaryTextColor(), false); + int titleY = widget.y() + Math.max(2, (widget.headerHeight() - textRenderer.lineHeight) / 2); + context.drawString(textRenderer, GraphEditorTranslations.nodeTitle(widget.i18n(), widget.nodeType()), widget.x() + widget.edgePadding(), titleY, theme.primaryTextColor(), false); } if (!compactReroute && detailLevel == NodeRenderDetailLevel.FULL) { @@ -76,8 +75,8 @@ static void render( } private static void renderRow( - DrawContext context, - TextRenderer textRenderer, + GuiGraphics context, + Font textRenderer, NodeWidget widget, NodeWidget.RowWidget row, GraphEditorUiConfig uiConfig, @@ -96,8 +95,8 @@ private static void renderRow( } private static void renderInputRow( - DrawContext context, - TextRenderer textRenderer, + GuiGraphics context, + Font textRenderer, NodeWidget widget, NodeWidget.InputPortRowWidget row, GraphEditorUiConfig uiConfig, @@ -106,8 +105,8 @@ private static void renderInputRow( ) { String label = trimText(textRenderer, GraphEditorTranslations.portLabel(widget.i18n(), widget.nodeType(), row.port().definition()), labelWidthForInputRow(widget, row)); int labelX = row.port().centerX() + row.port().radius() + 6; - int labelY = row.y() + Math.max(2, (row.height() - textRenderer.fontHeight) / 2); - context.drawText(textRenderer, label, labelX, labelY, theme.primaryTextColor(), false); + int labelY = row.y() + Math.max(2, (row.height() - textRenderer.lineHeight) / 2); + context.drawString(textRenderer, label, labelX, labelY, theme.primaryTextColor(), false); if (row.fieldBounds() == null || activePortEditor.test(row.port().nodeId(), row.port().definition().id())) { return; } @@ -128,21 +127,21 @@ private static void renderInputRow( ? GraphEditorTranslations.ui(widget.i18n(), "common.on", "On") : GraphEditorTranslations.ui(widget.i18n(), "common.off", "Off"); }; - context.drawText(textRenderer, trimText(textRenderer, renderedValue, row.fieldBounds().width() - 8), row.fieldBounds().x() + 4, row.fieldBounds().y() + 3, theme.secondaryTextColor(), false); + context.drawString(textRenderer, trimText(textRenderer, renderedValue, row.fieldBounds().width() - 8), row.fieldBounds().x() + 4, row.fieldBounds().y() + 3, theme.secondaryTextColor(), false); } - private static void renderOutputRow(DrawContext context, TextRenderer textRenderer, NodeWidget widget, NodeWidget.OutputPortRowWidget row, GraphEditorTheme theme) { + private static void renderOutputRow(GuiGraphics context, Font textRenderer, NodeWidget widget, NodeWidget.OutputPortRowWidget row, GraphEditorTheme theme) { String label = trimText(textRenderer, GraphEditorTranslations.portLabel(widget.i18n(), widget.nodeType(), row.port().definition()), outputLabelWidth(widget, row)); - int labelWidth = textRenderer.getWidth(label); + int labelWidth = textRenderer.width(label); int labelRight = row.port().centerX() - row.port().radius() - 6; int labelX = Math.max(widget.x() + widget.edgePadding(), labelRight - labelWidth); - int labelY = row.y() + Math.max(2, (row.height() - textRenderer.fontHeight) / 2); - context.drawText(textRenderer, label, labelX, labelY, theme.primaryTextColor(), false); + int labelY = row.y() + Math.max(2, (row.height() - textRenderer.lineHeight) / 2); + context.drawString(textRenderer, label, labelX, labelY, theme.primaryTextColor(), false); } private static void renderTextControlRow( - DrawContext context, - TextRenderer textRenderer, + GuiGraphics context, + Font textRenderer, NodeWidget widget, NodeWidget.TextControlRowWidget row, GraphEditorUiConfig uiConfig, @@ -150,7 +149,7 @@ private static void renderTextControlRow( BiPredicate activeControlEditor ) { if (row.labelVisible()) { - context.drawText(textRenderer, trimText(textRenderer, GraphEditorTranslations.controlLabel(widget.i18n(), widget.nodeType(), row.control()), labelWidthForControlRow(widget, row.fieldBounds())), widget.x() + widget.edgePadding(), row.y() + Math.max(2, (row.height() - textRenderer.fontHeight) / 2), theme.primaryTextColor(), false); + context.drawString(textRenderer, trimText(textRenderer, GraphEditorTranslations.controlLabel(widget.i18n(), widget.nodeType(), row.control()), labelWidthForControlRow(widget, row.fieldBounds())), widget.x() + widget.edgePadding(), row.y() + Math.max(2, (row.height() - textRenderer.lineHeight) / 2), theme.primaryTextColor(), false); } if (activeControlEditor.test(widget.node().id(), row.control().key())) { return; @@ -160,36 +159,36 @@ private static void renderTextControlRow( : EditorStyleRenderer.darken(theme.nodeHeaderColor(), 0.04f); EditorStyleRenderer.drawBox(context, row.fieldBounds().x(), row.fieldBounds().y(), row.fieldBounds().width(), row.fieldBounds().height(), fill, theme.panelBorderColor(), uiConfig); String value = NodeConfigValues.readTextControlValue(widget.node().config(), row.control()); - context.drawText(textRenderer, trimText(textRenderer, value, row.fieldBounds().width() - 8), row.fieldBounds().x() + 4, row.fieldBounds().y() + 3, theme.secondaryTextColor(), false); + context.drawString(textRenderer, trimText(textRenderer, value, row.fieldBounds().width() - 8), row.fieldBounds().x() + 4, row.fieldBounds().y() + 3, theme.secondaryTextColor(), false); } private static void renderNumericTextControlRow( - DrawContext context, - TextRenderer textRenderer, + GuiGraphics context, + Font textRenderer, NodeWidget widget, NodeWidget.NumericTextControlRowWidget row, GraphEditorUiConfig uiConfig, GraphEditorTheme theme, BiPredicate activeControlEditor ) { - context.drawText(textRenderer, trimText(textRenderer, GraphEditorTranslations.controlLabel(widget.i18n(), widget.nodeType(), row.control()), labelWidthForControlRow(widget, row.fieldBounds())), widget.x() + widget.edgePadding(), row.y() + Math.max(2, (row.height() - textRenderer.fontHeight) / 2), theme.primaryTextColor(), false); + context.drawString(textRenderer, trimText(textRenderer, GraphEditorTranslations.controlLabel(widget.i18n(), widget.nodeType(), row.control()), labelWidthForControlRow(widget, row.fieldBounds())), widget.x() + widget.edgePadding(), row.y() + Math.max(2, (row.height() - textRenderer.lineHeight) / 2), theme.primaryTextColor(), false); if (activeControlEditor.test(widget.node().id(), row.control().key())) { return; } EditorStyleRenderer.drawBox(context, row.fieldBounds().x(), row.fieldBounds().y(), row.fieldBounds().width(), row.fieldBounds().height(), EditorStyleRenderer.darken(theme.nodeBodyColor(), 0.04f), theme.panelBorderColor(), uiConfig); String value = NodeConfigValues.readNumericTextControlValue(widget.node().config(), row.control()); - context.drawText(textRenderer, trimText(textRenderer, value, row.fieldBounds().width() - 8), row.fieldBounds().x() + 4, row.fieldBounds().y() + 3, theme.secondaryTextColor(), false); + context.drawString(textRenderer, trimText(textRenderer, value, row.fieldBounds().width() - 8), row.fieldBounds().x() + 4, row.fieldBounds().y() + 3, theme.secondaryTextColor(), false); } private static void renderBooleanControlRow( - DrawContext context, - TextRenderer textRenderer, + GuiGraphics context, + Font textRenderer, NodeWidget widget, NodeWidget.BooleanControlRowWidget row, GraphEditorUiConfig uiConfig, GraphEditorTheme theme ) { - context.drawText(textRenderer, trimText(textRenderer, GraphEditorTranslations.controlLabel(widget.i18n(), widget.nodeType(), row.control()), labelWidthForControlRow(widget, row.toggleBounds())), widget.x() + widget.edgePadding(), row.y() + Math.max(2, (row.height() - textRenderer.fontHeight) / 2), theme.primaryTextColor(), false); + context.drawString(textRenderer, trimText(textRenderer, GraphEditorTranslations.controlLabel(widget.i18n(), widget.nodeType(), row.control()), labelWidthForControlRow(widget, row.toggleBounds())), widget.x() + widget.edgePadding(), row.y() + Math.max(2, (row.height() - textRenderer.lineHeight) / 2), theme.primaryTextColor(), false); boolean value = NodeConfigValues.readBooleanControlValue(widget.node().config(), row.control()); int fill = value ? EditorStyleRenderer.blend(theme.nodeBodyColor(), theme.accentColor(), 0.2f) : EditorStyleRenderer.darken(theme.nodeBodyColor(), 0.04f); int border = value ? theme.accentColor() : theme.panelBorderColor(); @@ -197,35 +196,35 @@ private static void renderBooleanControlRow( String label = value ? GraphEditorTranslations.ui(widget.i18n(), "common.enabled", "Enabled") : GraphEditorTranslations.ui(widget.i18n(), "common.disabled", "Disabled"); - context.drawText(textRenderer, trimText(textRenderer, label, row.toggleBounds().width() - 8), row.toggleBounds().x() + 4, row.toggleBounds().y() + 3, theme.secondaryTextColor(), false); + context.drawString(textRenderer, trimText(textRenderer, label, row.toggleBounds().width() - 8), row.toggleBounds().x() + 4, row.toggleBounds().y() + 3, theme.secondaryTextColor(), false); } private static void renderCycleControlRow( - DrawContext context, - TextRenderer textRenderer, + GuiGraphics context, + Font textRenderer, NodeWidget widget, NodeWidget.CycleControlRowWidget row, GraphEditorUiConfig uiConfig, GraphEditorTheme theme ) { - context.drawText(textRenderer, trimText(textRenderer, GraphEditorTranslations.controlLabel(widget.i18n(), widget.nodeType(), row.control()), labelWidthForControlRow(widget, row.valueBounds())), widget.x() + widget.edgePadding(), row.y() + Math.max(2, (row.height() - textRenderer.fontHeight) / 2), theme.primaryTextColor(), false); + context.drawString(textRenderer, trimText(textRenderer, GraphEditorTranslations.controlLabel(widget.i18n(), widget.nodeType(), row.control()), labelWidthForControlRow(widget, row.valueBounds())), widget.x() + widget.edgePadding(), row.y() + Math.max(2, (row.height() - textRenderer.lineHeight) / 2), theme.primaryTextColor(), false); EditorStyleRenderer.drawBox(context, row.leftArrowBounds().x(), row.leftArrowBounds().y(), row.leftArrowBounds().width(), row.leftArrowBounds().height(), EditorStyleRenderer.darken(theme.nodeBodyColor(), 0.04f), theme.panelBorderColor(), uiConfig); EditorStyleRenderer.drawBox(context, row.valueBounds().x(), row.valueBounds().y(), row.valueBounds().width(), row.valueBounds().height(), EditorStyleRenderer.darken(theme.nodeBodyColor(), 0.04f), theme.panelBorderColor(), uiConfig); EditorStyleRenderer.drawBox(context, row.rightArrowBounds().x(), row.rightArrowBounds().y(), row.rightArrowBounds().width(), row.rightArrowBounds().height(), EditorStyleRenderer.darken(theme.nodeBodyColor(), 0.04f), theme.panelBorderColor(), uiConfig); - context.drawText(textRenderer, "<", row.leftArrowBounds().x() + 4, row.leftArrowBounds().y() + 3, theme.secondaryTextColor(), false); - context.drawText(textRenderer, ">", row.rightArrowBounds().x() + 4, row.rightArrowBounds().y() + 3, theme.secondaryTextColor(), false); + context.drawString(textRenderer, "<", row.leftArrowBounds().x() + 4, row.leftArrowBounds().y() + 3, theme.secondaryTextColor(), false); + context.drawString(textRenderer, ">", row.rightArrowBounds().x() + 4, row.rightArrowBounds().y() + 3, theme.secondaryTextColor(), false); String currentId = NodeConfigValues.readCycleControlValue(widget.node().config(), row.control()); String currentLabel = row.control().options().stream() .filter(option -> option.id().equals(currentId)) .map(option -> GraphEditorTranslations.controlOptionLabel(widget.i18n(), widget.nodeType(), row.control(), option)) .findFirst() .orElse(currentId); - context.drawText(textRenderer, trimText(textRenderer, currentLabel, row.valueBounds().width() - 8), row.valueBounds().x() + 4, row.valueBounds().y() + 3, theme.secondaryTextColor(), false); + context.drawString(textRenderer, trimText(textRenderer, currentLabel, row.valueBounds().width() - 8), row.valueBounds().x() + 4, row.valueBounds().y() + 3, theme.secondaryTextColor(), false); } private static void renderBody( - DrawContext context, - TextRenderer textRenderer, + GuiGraphics context, + Font textRenderer, NodeWidget widget, GraphEditorTheme theme, GraphEditorUiConfig uiConfig, @@ -268,17 +267,17 @@ private static int labelWidthForControlRow(NodeWidget widget, NodeWidget.Bounds return Math.max(18, controlBounds.x() - (widget.x() + widget.edgePadding()) - 6); } - private static String trimText(TextRenderer textRenderer, String value, int maxWidth) { - if (textRenderer.getWidth(value) <= maxWidth) { + private static String trimText(Font textRenderer, String value, int maxWidth) { + if (textRenderer.width(value) <= maxWidth) { return value; } String ellipsis = "..."; - int ellipsisWidth = textRenderer.getWidth(ellipsis); + int ellipsisWidth = textRenderer.width(ellipsis); if (ellipsisWidth >= maxWidth) { return ""; } String candidate = value; - while (!candidate.isEmpty() && textRenderer.getWidth(candidate) + ellipsisWidth > maxWidth) { + while (!candidate.isEmpty() && textRenderer.width(candidate) + ellipsisWidth > maxWidth) { candidate = candidate.substring(0, candidate.length() - 1); } return candidate + ellipsis; diff --git a/mcng-fabric-client/src/test/java/com/github/squi2rel/mcng/fabric/client/GraphEditorComponentPlacementTest.java b/mcng-fabric-client/src/test/java/com/github/squi2rel/mcng/fabric/client/GraphEditorComponentPlacementTest.java index feab9b0..7ffa51a 100644 --- a/mcng-fabric-client/src/test/java/com/github/squi2rel/mcng/fabric/client/GraphEditorComponentPlacementTest.java +++ b/mcng-fabric-client/src/test/java/com/github/squi2rel/mcng/fabric/client/GraphEditorComponentPlacementTest.java @@ -7,6 +7,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; + class GraphEditorComponentPlacementTest { @Test void placementPositionMatchesPreviewForRegularNode() { diff --git a/mcng-fabric-client/src/test/java/com/github/squi2rel/mcng/fabric/client/GraphEditorComponentTest.java b/mcng-fabric-client/src/test/java/com/github/squi2rel/mcng/fabric/client/GraphEditorComponentTest.java index ae97b4b..3f43e8e 100644 --- a/mcng-fabric-client/src/test/java/com/github/squi2rel/mcng/fabric/client/GraphEditorComponentTest.java +++ b/mcng-fabric-client/src/test/java/com/github/squi2rel/mcng/fabric/client/GraphEditorComponentTest.java @@ -26,6 +26,7 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; + class GraphEditorComponentTest { @Test void resizingEditorDoesNotResetPaletteScrollOffset() throws ReflectiveOperationException { diff --git a/mcng-fabric-client/src/test/java/com/github/squi2rel/mcng/fabric/client/MCNGDebugScreenLayoutTest.java b/mcng-fabric-client/src/test/java/com/github/squi2rel/mcng/fabric/client/MCNGDebugScreenLayoutTest.java index cc24833..cbee141 100644 --- a/mcng-fabric-client/src/test/java/com/github/squi2rel/mcng/fabric/client/MCNGDebugScreenLayoutTest.java +++ b/mcng-fabric-client/src/test/java/com/github/squi2rel/mcng/fabric/client/MCNGDebugScreenLayoutTest.java @@ -8,7 +8,6 @@ import com.github.squi2rel.mcng.core.NodeTypeRegistry; import com.github.squi2rel.mcng.core.PortTypeRegistry; import com.github.squi2rel.mcng.core.builtin.BuiltinNodeRegistrar; -import net.minecraft.client.gui.screen.Screen; import org.junit.jupiter.api.Test; import java.lang.reflect.Field; @@ -16,10 +15,12 @@ import java.util.List; import java.util.Map; import java.util.function.Consumer; +import net.minecraft.client.gui.screens.Screen; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; + class MCNGDebugScreenLayoutTest { @Test void debugPanelButtonsStayInsidePanelAndDoNotOverlapVariables() throws ReflectiveOperationException { diff --git a/mcng-fabric-client/src/test/java/com/github/squi2rel/mcng/fabric/client/NodeBodyComponentIntegrationTest.java b/mcng-fabric-client/src/test/java/com/github/squi2rel/mcng/fabric/client/NodeBodyComponentIntegrationTest.java index 7f233bb..5fdd4cc 100644 --- a/mcng-fabric-client/src/test/java/com/github/squi2rel/mcng/fabric/client/NodeBodyComponentIntegrationTest.java +++ b/mcng-fabric-client/src/test/java/com/github/squi2rel/mcng/fabric/client/NodeBodyComponentIntegrationTest.java @@ -22,6 +22,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; + class NodeBodyComponentIntegrationTest { @Test void nodeWidgetAllocatesBodyFromStoredLayoutSize() { diff --git a/mcng-fabric-client/src/test/java/com/github/squi2rel/mcng/fabric/client/NodePaletteCatalogTest.java b/mcng-fabric-client/src/test/java/com/github/squi2rel/mcng/fabric/client/NodePaletteCatalogTest.java index a7fadf3..70f5865 100644 --- a/mcng-fabric-client/src/test/java/com/github/squi2rel/mcng/fabric/client/NodePaletteCatalogTest.java +++ b/mcng-fabric-client/src/test/java/com/github/squi2rel/mcng/fabric/client/NodePaletteCatalogTest.java @@ -17,6 +17,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; + class NodePaletteCatalogTest { @Test void groupsRegisteredPaletteEntriesIntoExpectedSections() { diff --git a/mcng-fabric-client/src/test/java/com/github/squi2rel/mcng/fabric/client/NodePaletteComponentTest.java b/mcng-fabric-client/src/test/java/com/github/squi2rel/mcng/fabric/client/NodePaletteComponentTest.java index 97b2908..dadda4f 100644 --- a/mcng-fabric-client/src/test/java/com/github/squi2rel/mcng/fabric/client/NodePaletteComponentTest.java +++ b/mcng-fabric-client/src/test/java/com/github/squi2rel/mcng/fabric/client/NodePaletteComponentTest.java @@ -11,6 +11,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; + class NodePaletteComponentTest { @Test void mouseWheelScrollsPaletteContent() throws ReflectiveOperationException { diff --git a/mcng-fabric-client/src/test/java/com/github/squi2rel/mcng/fabric/client/NodeWidgetInlineLayoutTest.java b/mcng-fabric-client/src/test/java/com/github/squi2rel/mcng/fabric/client/NodeWidgetInlineLayoutTest.java index 2b1a888..53b7090 100644 --- a/mcng-fabric-client/src/test/java/com/github/squi2rel/mcng/fabric/client/NodeWidgetInlineLayoutTest.java +++ b/mcng-fabric-client/src/test/java/com/github/squi2rel/mcng/fabric/client/NodeWidgetInlineLayoutTest.java @@ -33,6 +33,8 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; + + import static org.junit.jupiter.api.Assertions.assertFalse; class NodeWidgetInlineLayoutTest { diff --git a/paper-plugin-26.2/build.gradle b/paper-plugin-26.2/build.gradle new file mode 100644 index 0000000..b568db3 --- /dev/null +++ b/paper-plugin-26.2/build.gradle @@ -0,0 +1,130 @@ +plugins { + id 'java' + id 'com.gradleup.shadow' +} + +base { + archivesName = "${rootProject.archives_base_name}-Paper" +} + +repositories { + mavenCentral() + maven { + name = 'Paper' + url = 'https://repo.papermc.io/repository/maven-public/' + } +} + +sourceSets { + main { + java.setSrcDirs([rootProject.file('paper-plugin/src/main/java')]) + resources.setSrcDirs([rootProject.file('paper-plugin/src/main/resources')]) + } + test { + java.setSrcDirs([rootProject.file('paper-plugin/src/test/java')]) + resources.setSrcDirs([rootProject.file('paper-plugin/src/test/resources')]) + } +} + +def sharedMainSources = fileTree("${rootProject.projectDir}/src/main/java") { + exclude 'android/**' + exclude 'com/github/squi2rel/vp/VideoPlayerMain.java' + exclude 'com/github/squi2rel/vp/DataHolder.java' + exclude 'com/github/squi2rel/vp/network/ServerPacketHandler.java' + exclude 'com/github/squi2rel/vp/network/VideoPayload.java' + exclude 'com/github/squi2rel/vp/network/ClientMessageBridge.java' + exclude 'com/github/squi2rel/vp/provider/PlayerProviderSource.java' + exclude 'com/github/squi2rel/vp/permission/VideoPermissions.java' + exclude 'com/github/squi2rel/vp/video/ScreenBroadcaster.java' + exclude 'com/github/squi2rel/vp/video/VideoArea.java' + exclude 'com/github/squi2rel/vp/i18n/MinecraftTexts.java' +} + +dependencies { + compileOnly "io.papermc.paper:paper-api:${rootProject.paper_api_26_2_version}" + compileOnly 'org.jetbrains:annotations:26.0.2' + compileOnly 'org.slf4j:slf4j-api:2.0.16' + implementation 'io.netty:netty-buffer:4.1.118.Final' + implementation 'org.joml:joml:1.10.8' + implementation 'com.google.code.gson:gson:2.11.0' + implementation 'net.java.dev.jna:jna:5.17.0' + implementation 'org.brotli:dec:0.1.2' + testImplementation platform('org.junit:junit-bom:5.11.4') + testImplementation 'org.junit.jupiter:junit-jupiter' + testImplementation "org.mockito:mockito-core:${rootProject.mockito_version}" + testImplementation "io.papermc.paper:paper-api:${rootProject.paper_api_26_2_version}" + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +tasks.test { + useJUnitPlatform() + workingDir rootProject.file('paper-plugin') +} + +tasks.processResources { + doFirst { + java.nio.file.Files.deleteIfExists(destinationDir.toPath().resolve('assets/videoplayer/native/libmpv-windows-x64.zip')) + } + inputs.property 'version', project.version + inputs.property 'apiVersion', '26.2' + from("${rootProject.projectDir}/src/main/resources/assets/videoplayer/native-downloads.json") { + into 'assets/videoplayer' + } + filesMatching('plugin.yml') { + expand 'version': project.version, 'apiVersion': '26.2' + } +} + +tasks.withType(JavaCompile).configureEach { + options.release = 25 + options.encoding = 'UTF-8' +} + +tasks.named('compileJava', JavaCompile) { + source sharedMainSources +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(25) + } + sourceCompatibility = JavaVersion.VERSION_25 + targetCompatibility = JavaVersion.VERSION_25 +} + +tasks.jar { + archiveClassifier = 'plain' + archiveVersion = "${project.version}-26.2" +} + +tasks.shadowJar { + archiveClassifier = '' + archiveVersion = "${project.version}-26.2" + mergeServiceFiles() + relocate 'com.google.gson', 'com.github.squi2rel.vp.paper.shadow.gson' + relocate 'io.netty', 'com.github.squi2rel.vp.paper.shadow.netty' + relocate 'org.brotli', 'com.github.squi2rel.vp.paper.shadow.brotli' + relocate 'org.joml', 'com.github.squi2rel.vp.paper.shadow.joml' +} + +def shadowJarTask = tasks.named('shadowJar') + +tasks.register('verifyPaperPluginDescriptor') { + dependsOn shadowJarTask + inputs.file(shadowJarTask.flatMap { it.archiveFile }) + doLast { + def archive = shadowJarTask.get().archiveFile.get().asFile + java.util.jar.JarFile jar = new java.util.jar.JarFile(archive) + try { + if (jar.getJarEntry('plugin.yml') == null) { + throw new GradleException("Paper plugin archive does not contain plugin.yml: ${archive}") + } + } finally { + jar.close() + } + } +} + +tasks.build { + dependsOn tasks.verifyPaperPluginDescriptor +} diff --git a/paper-plugin/build.gradle b/paper-plugin/build.gradle index 8b8c55f..00781e7 100644 --- a/paper-plugin/build.gradle +++ b/paper-plugin/build.gradle @@ -1,6 +1,6 @@ plugins { id 'java' - id 'com.gradleup.shadow' version '9.2.2' + id 'com.gradleup.shadow' } version = rootProject.mod_version @@ -36,6 +36,7 @@ def sharedMainSources = fileTree("${rootProject.projectDir}/src/main/java") { exclude 'com/github/squi2rel/vp/DataHolder.java' exclude 'com/github/squi2rel/vp/network/ServerPacketHandler.java' exclude 'com/github/squi2rel/vp/network/VideoPayload.java' + exclude 'com/github/squi2rel/vp/network/ClientMessageBridge.java' exclude 'com/github/squi2rel/vp/provider/PlayerProviderSource.java' exclude 'com/github/squi2rel/vp/permission/VideoPermissions.java' exclude 'com/github/squi2rel/vp/video/ScreenBroadcaster.java' @@ -55,7 +56,7 @@ dependencies { testImplementation platform("org.junit:junit-bom:5.11.4") testImplementation "org.junit.jupiter:junit-jupiter" - testImplementation "org.mockito:mockito-core:5.15.2" + testImplementation "org.mockito:mockito-core:${rootProject.mockito_version}" testImplementation "io.papermc.paper:paper-api:${rootProject.paper_api_version}" testRuntimeOnly "org.junit.platform:junit-platform-launcher" } @@ -64,45 +65,17 @@ tasks.test { useJUnitPlatform() } -def bundledMpvUrl = 'https://github.com/squi2rel/VideoPlayer-Library/releases/download/runtime-20260712-064900/libmpv-windows-x64.zip' -def bundledMpvSha256 = '0a1e614d3b3db315895d19b1e97013fd12da9bc20c50d02d5de3b71a959dfdfb' -def bundledMpvZip = layout.buildDirectory.file('bundled-native/libmpv-windows-x64.zip') - -tasks.register('downloadBundledWindowsMpv') { - outputs.file(bundledMpvZip) - doLast { - def target = bundledMpvZip.get().asFile - target.parentFile.mkdirs() - if (target.isFile()) { - def digest = java.security.MessageDigest.getInstance('SHA-256').digest(target.bytes).encodeHex().toString() - if (digest == bundledMpvSha256) return - target.delete() - } - def temporary = new File(target.parentFile, target.name + '.tmp') - temporary.delete() - new URI(bundledMpvUrl).toURL().withInputStream { input -> - temporary.withOutputStream { output -> output << input } - } - def digest = java.security.MessageDigest.getInstance('SHA-256').digest(temporary.bytes).encodeHex().toString() - if (digest != bundledMpvSha256) { - temporary.delete() - throw new GradleException("Bundled MPV SHA-256 mismatch: ${digest}") - } - if (!temporary.renameTo(target)) throw new GradleException('Failed to store bundled MPV package') - } -} - tasks.processResources { - dependsOn tasks.named('downloadBundledWindowsMpv') + doFirst { + java.nio.file.Files.deleteIfExists(destinationDir.toPath().resolve('assets/videoplayer/native/libmpv-windows-x64.zip')) + } inputs.property "version", project.version + inputs.property "apiVersion", '1.21' from("${rootProject.projectDir}/src/main/resources/assets/videoplayer/native-downloads.json") { into 'assets/videoplayer' } - from(bundledMpvZip) { - into 'assets/videoplayer/native' - } filesMatching("plugin.yml") { - expand "version": project.version + expand "version": project.version, "apiVersion": '1.21' } } @@ -118,6 +91,9 @@ tasks.withType(JavaCompile).configureEach { } java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } diff --git a/paper-plugin/src/main/java/com/github/squi2rel/vp/DisplayCleanupService.java b/paper-plugin/src/main/java/com/github/squi2rel/vp/DisplayCleanupService.java new file mode 100644 index 0000000..033d00b --- /dev/null +++ b/paper-plugin/src/main/java/com/github/squi2rel/vp/DisplayCleanupService.java @@ -0,0 +1,261 @@ +package com.github.squi2rel.vp; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.NamespacedKey; +import org.bukkit.World; +import org.bukkit.entity.Entity; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.plugin.Plugin; + +import java.util.HashMap; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.ReentrantLock; + +public final class DisplayCleanupService { + public static final int API_VERSION = 1; + private static final int MAX_GROUPS = 32; + private static final int MAX_RECORDS_PER_GROUP = 4096; + private static volatile DisplayCleanupService current; + + private final CleanupExecutor executor; + private final ReentrantLock lock = new ReentrantLock(); + private final HashMap> groups = new HashMap<>(); + + DisplayCleanupService(CleanupExecutor executor) { + this.executor = Objects.requireNonNull(executor, "executor"); + } + + public static int apiVersion() { + return API_VERSION; + } + + public static UUID openGroup() { + return UUID.randomUUID(); + } + + public static boolean available() { + return current != null; + } + + public static boolean track(UUID groupId, DisplayRecord record) { + DisplayCleanupService service = current; + return service != null && service.trackRecord(groupId, record); + } + + public static boolean untrack(UUID groupId, UUID entityId) { + DisplayCleanupService service = current; + return service != null && service.untrackRecord(groupId, entityId); + } + + public static int cleanup(UUID groupId) { + DisplayCleanupService service = current; + return service == null ? 0 : service.cleanupGroup(groupId); + } + + static synchronized void initialize(CleanupExecutor executor) { + DisplayCleanupService previous = current; + current = new DisplayCleanupService(executor); + if (previous != null) previous.close(); + } + + static synchronized void initialize(Plugin plugin) { + initialize(new BukkitCleanupExecutor(plugin)); + } + + static synchronized void shutdown() { + DisplayCleanupService previous = current; + current = null; + if (previous != null) previous.close(); + } + + boolean trackRecord(UUID groupId, DisplayRecord record) { + if (groupId == null) throw new IllegalArgumentException("group id is required"); + Objects.requireNonNull(record, "record"); + lock.lock(); + try { + HashMap records = groups.get(groupId); + if (records == null) { + if (groups.size() >= MAX_GROUPS) throw new IllegalStateException("display cleanup group limit exceeded"); + records = new HashMap<>(); + groups.put(groupId, records); + } + if (!records.containsKey(record.entityId()) && records.size() >= MAX_RECORDS_PER_GROUP) { + throw new IllegalStateException("display cleanup record limit exceeded"); + } + return records.put(record.entityId(), record) == null; + } finally { + lock.unlock(); + } + } + + boolean untrackRecord(UUID groupId, UUID entityId) { + if (groupId == null || entityId == null) return false; + lock.lock(); + try { + HashMap records = groups.get(groupId); + if (records == null || records.remove(entityId) == null) return false; + if (records.isEmpty()) groups.remove(groupId); + return true; + } finally { + lock.unlock(); + } + } + + int cleanupGroup(UUID groupId) { + if (groupId == null) return 0; + List records; + lock.lock(); + try { + HashMap removed = groups.remove(groupId); + if (removed == null || removed.isEmpty()) return 0; + records = List.copyOf(removed.values()); + } finally { + lock.unlock(); + } + for (DisplayRecord record : records) { + try { + executor.dispatch(record); + } catch (RuntimeException error) { + VideoPlayerMain.LOGGER.warn("Failed to dispatch display cleanup for {}", record.entityId(), error); + } + } + return records.size(); + } + + int trackedRecords() { + lock.lock(); + try { + int total = 0; + for (HashMap records : groups.values()) total += records.size(); + return total; + } finally { + lock.unlock(); + } + } + + private void clear() { + lock.lock(); + try { + groups.clear(); + } finally { + lock.unlock(); + } + } + + private void close() { + clear(); + executor.close(); + } + + @FunctionalInterface + interface CleanupExecutor extends AutoCloseable { + void dispatch(DisplayRecord record); + + @Override + default void close() { + } + } + + public record DisplayRecord(UUID entityId, String worldKey, double x, double y, double z, String markerKey) { + public DisplayRecord { + if (entityId == null) throw new IllegalArgumentException("entity id is required"); + worldKey = validateText(worldKey, "world key"); + markerKey = validateText(markerKey, "marker key"); + if (!Double.isFinite(x) || !Double.isFinite(y) || !Double.isFinite(z)) { + throw new IllegalArgumentException("display coordinates must be finite"); + } + } + + private static String validateText(String value, String name) { + if (value == null || value.isBlank() || value.length() > 128) { + throw new IllegalArgumentException(name + " is invalid"); + } + return value; + } + } + + private static final class BukkitCleanupExecutor implements CleanupExecutor { + private final Plugin plugin; + private final Set tasks = ConcurrentHashMap.newKeySet(); + private final AtomicBoolean closed = new AtomicBoolean(); + + private BukkitCleanupExecutor(Plugin plugin) { + this.plugin = Objects.requireNonNull(plugin, "plugin"); + } + + @Override + public void dispatch(DisplayRecord record) { + if (closed.get() || !plugin.isEnabled()) return; + AtomicReference reference = new AtomicReference<>(FoliaScheduler.TaskHandle.NONE); + FoliaScheduler.TaskHandle task = FoliaScheduler.runGlobal(plugin, () -> { + tasks.remove(reference.get()); + scheduleRegion(record); + }); + reference.set(task); + tasks.add(task); + } + + private void scheduleRegion(DisplayRecord record) { + if (closed.get() || !plugin.isEnabled()) return; + NamespacedKey worldKey = NamespacedKey.fromString(record.worldKey()); + NamespacedKey markerKey = NamespacedKey.fromString(record.markerKey()); + World world = worldKey == null ? null : Bukkit.getWorld(worldKey); + if (world == null || markerKey == null) return; + Location location = new Location(world, record.x(), record.y(), record.z()); + AtomicReference reference = new AtomicReference<>(FoliaScheduler.TaskHandle.NONE); + FoliaScheduler.TaskHandle task = FoliaScheduler.runAtRegionDelayed(plugin, location, () -> { + tasks.remove(reference.get()); + cleanupAtRegion(record, location, markerKey); + }, 1L); + reference.set(task); + if (task != FoliaScheduler.TaskHandle.NONE) tasks.add(task); + } + + private void cleanupAtRegion(DisplayRecord record, Location location, NamespacedKey markerKey) { + if (closed.get() || !location.isChunkLoaded()) return; + Entity entity = location.getWorld().getEntity(record.entityId()); + if (entity == null) return; + if (FoliaScheduler.isFolia() && !Bukkit.isOwnedByCurrentRegion(entity)) { + scheduleEntity(record, entity, markerKey); + return; + } + removeMarked(entity, record.entityId(), markerKey); + } + + private void scheduleEntity(DisplayRecord record, Entity entity, NamespacedKey markerKey) { + if (closed.get() || !plugin.isEnabled()) return; + AtomicReference reference = new AtomicReference<>(FoliaScheduler.TaskHandle.NONE); + FoliaScheduler.TaskHandle task = FoliaScheduler.runAtEntityDelayed( + plugin, + entity, + () -> { + tasks.remove(reference.get()); + removeMarked(entity, record.entityId(), markerKey); + }, + () -> tasks.remove(reference.get()), + 1L + ); + reference.set(task); + if (task != FoliaScheduler.TaskHandle.NONE) tasks.add(task); + } + + private void removeMarked(Entity entity, UUID entityId, NamespacedKey markerKey) { + if (!entity.getUniqueId().equals(entityId)) return; + if (entity.getPersistentDataContainer().has(markerKey, PersistentDataType.BYTE)) entity.remove(); + } + + @Override + public void close() { + if (!closed.compareAndSet(false, true)) return; + for (FoliaScheduler.TaskHandle task : List.copyOf(tasks)) task.cancel(); + tasks.clear(); + } + } +} diff --git a/paper-plugin/src/main/java/com/github/squi2rel/vp/FoliaScheduler.java b/paper-plugin/src/main/java/com/github/squi2rel/vp/FoliaScheduler.java index 9d07bc3..4de55b6 100644 --- a/paper-plugin/src/main/java/com/github/squi2rel/vp/FoliaScheduler.java +++ b/paper-plugin/src/main/java/com/github/squi2rel/vp/FoliaScheduler.java @@ -4,6 +4,7 @@ import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.entity.Entity; +import org.bukkit.plugin.Plugin; import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.scheduler.BukkitTask; @@ -55,7 +56,11 @@ public static synchronized void shutdown(JavaPlugin plugin) { } public static TaskHandle runGlobal(Runnable runnable) { - JavaPlugin plugin = requireOwner(); + return runGlobal(requireOwner(), runnable); + } + + public static TaskHandle runGlobal(Plugin taskOwner, Runnable runnable) { + Plugin plugin = requireTaskOwner(taskOwner); if (isFolia()) { ScheduledTask task = Bukkit.getGlobalRegionScheduler().run(plugin, ignored -> runnable.run()); return task::cancel; @@ -65,7 +70,11 @@ public static TaskHandle runGlobal(Runnable runnable) { } public static TaskHandle runGlobalDelayed(Runnable runnable, long ticks) { - JavaPlugin plugin = requireOwner(); + return runGlobalDelayed(requireOwner(), runnable, ticks); + } + + public static TaskHandle runGlobalDelayed(Plugin taskOwner, Runnable runnable, long ticks) { + Plugin plugin = requireTaskOwner(taskOwner); long delay = Math.max(1L, ticks); if (isFolia()) { ScheduledTask task = Bukkit.getGlobalRegionScheduler().runDelayed(plugin, ignored -> runnable.run(), delay); @@ -76,7 +85,11 @@ public static TaskHandle runGlobalDelayed(Runnable runnable, long ticks) { } public static TaskHandle runGlobalFixedRate(Runnable runnable, long initialDelayTicks, long periodTicks) { - JavaPlugin plugin = requireOwner(); + return runGlobalFixedRate(requireOwner(), runnable, initialDelayTicks, periodTicks); + } + + public static TaskHandle runGlobalFixedRate(Plugin taskOwner, Runnable runnable, long initialDelayTicks, long periodTicks) { + Plugin plugin = requireTaskOwner(taskOwner); long initialDelay = Math.max(1L, initialDelayTicks); long period = Math.max(1L, periodTicks); if (isFolia()) { @@ -88,7 +101,11 @@ public static TaskHandle runGlobalFixedRate(Runnable runnable, long initialDelay } public static TaskHandle runAsync(Runnable runnable) { - JavaPlugin plugin = requireOwner(); + return runAsync(requireOwner(), runnable); + } + + public static TaskHandle runAsync(Plugin taskOwner, Runnable runnable) { + Plugin plugin = requireTaskOwner(taskOwner); if (isFolia()) { ScheduledTask task = Bukkit.getAsyncScheduler().runNow(plugin, ignored -> runnable.run()); return task::cancel; @@ -105,8 +122,12 @@ public static TaskHandle runAtRegion(Location location, Runnable runnable) { } public static TaskHandle runAtRegionDelayed(Location location, Runnable runnable, long ticks) { + return runAtRegionDelayed(requireOwner(), location, runnable, ticks); + } + + public static TaskHandle runAtRegionDelayed(Plugin taskOwner, Location location, Runnable runnable, long ticks) { if (location == null || runnable == null) return TaskHandle.NONE; - JavaPlugin plugin = requireOwner(); + Plugin plugin = requireTaskOwner(taskOwner); long delay = minimumEntityDelay(ticks); if (isFolia()) { ScheduledTask task = Bukkit.getRegionScheduler().runDelayed(plugin, location, ignored -> runnable.run(), delay); @@ -117,8 +138,12 @@ public static TaskHandle runAtRegionDelayed(Location location, Runnable runnable } public static TaskHandle runAtRegionFixedRate(Location location, Runnable runnable, long initialDelayTicks, long periodTicks) { + return runAtRegionFixedRate(requireOwner(), location, runnable, initialDelayTicks, periodTicks); + } + + public static TaskHandle runAtRegionFixedRate(Plugin taskOwner, Location location, Runnable runnable, long initialDelayTicks, long periodTicks) { if (location == null || runnable == null) return TaskHandle.NONE; - JavaPlugin plugin = requireOwner(); + Plugin plugin = requireTaskOwner(taskOwner); long initialDelay = minimumEntityDelay(initialDelayTicks); long period = Math.max(1L, periodTicks); if (isFolia()) { @@ -143,8 +168,12 @@ public static TaskHandle runAtEntity(Entity entity, Runnable runnable, Runnable } public static TaskHandle runAtEntityDelayed(Entity entity, Runnable runnable, Runnable retired, long ticks) { + return runAtEntityDelayed(requireOwner(), entity, runnable, retired, ticks); + } + + public static TaskHandle runAtEntityDelayed(Plugin taskOwner, Entity entity, Runnable runnable, Runnable retired, long ticks) { if (entity == null || runnable == null) return TaskHandle.NONE; - JavaPlugin plugin = requireOwner(); + Plugin plugin = requireTaskOwner(taskOwner); long delay = minimumEntityDelay(ticks); if (isFolia()) { ScheduledTask task = entity.getScheduler().runDelayed(plugin, ignored -> runnable.run(), retired, delay); @@ -155,8 +184,12 @@ public static TaskHandle runAtEntityDelayed(Entity entity, Runnable runnable, Ru } public static TaskHandle runAtEntityFixedRate(Entity entity, Runnable runnable, Runnable retired, long initialDelayTicks, long periodTicks) { + return runAtEntityFixedRate(requireOwner(), entity, runnable, retired, initialDelayTicks, periodTicks); + } + + public static TaskHandle runAtEntityFixedRate(Plugin taskOwner, Entity entity, Runnable runnable, Runnable retired, long initialDelayTicks, long periodTicks) { if (entity == null || runnable == null) return TaskHandle.NONE; - JavaPlugin plugin = requireOwner(); + Plugin plugin = requireTaskOwner(taskOwner); long initialDelay = minimumEntityDelay(initialDelayTicks); long period = Math.max(1L, periodTicks); if (isFolia()) { @@ -177,6 +210,11 @@ private static JavaPlugin requireOwner() { return plugin; } + private static Plugin requireTaskOwner(Plugin taskOwner) { + if (taskOwner == null) throw new IllegalArgumentException("task owner is required"); + return taskOwner; + } + private static boolean detectFolia() { try { Class.forName("io.papermc.paper.threadedregions.RegionizedServer", false, FoliaScheduler.class.getClassLoader()); diff --git a/paper-plugin/src/main/java/com/github/squi2rel/vp/PaperNativeConfig.java b/paper-plugin/src/main/java/com/github/squi2rel/vp/PaperNativeConfig.java index 7fbecc1..15791b8 100644 --- a/paper-plugin/src/main/java/com/github/squi2rel/vp/PaperNativeConfig.java +++ b/paper-plugin/src/main/java/com/github/squi2rel/vp/PaperNativeConfig.java @@ -13,9 +13,6 @@ final class PaperNativeConfig { private static final String DEFAULT_BACKEND = NativeDownloadConfig.BACKEND_MPV; - private static final String BUNDLED_MPV_PLATFORM = "windows_x64"; - private static final String BUNDLED_MPV_RESOURCE = "/assets/videoplayer/native/libmpv-windows-x64.zip"; - private static final String BUNDLED_MPV_SHA256 = "0a1e614d3b3db315895d19b1e97013fd12da9bc20c50d02d5de3b71a959dfdfb"; private final String backend; private final String platform; @@ -165,21 +162,6 @@ void downloadIfMissing(BooleanSupplier active) { return; } - if (NativeDownloadConfig.BACKEND_MPV.equals(backend) && BUNDLED_MPV_PLATFORM.equals(platform)) { - if (!active.getAsBoolean()) return; - VideoPlayerMain.LOGGER.info("Installing bundled VideoPlayer native package {} {}", backend, platform); - NativePackageManager.DownloadResult bundled = NativePackageManager.installBundled( - backend, platform, BUNDLED_MPV_RESOURCE, BUNDLED_MPV_SHA256, active - ); - if (!active.getAsBoolean()) return; - if (bundled.success()) { - VideoPlayerMain.LOGGER.info("Installed bundled VideoPlayer native package {} {}", backend, platform); - return; - } - VideoPlayerMain.LOGGER.warn("Failed to install bundled VideoPlayer native package {} {}; falling back to download: {}", - backend, platform, message(bundled.message()), bundled.error()); - } - NativeDownloadConfig downloads = NativeDownloadConfig.load(); if (!active.getAsBoolean()) return; List sources = downloads.sources(backend, platform); diff --git a/paper-plugin/src/main/java/com/github/squi2rel/vp/PaperNativeRuntime.java b/paper-plugin/src/main/java/com/github/squi2rel/vp/PaperNativeRuntime.java index dcf401f..d67a0f0 100644 --- a/paper-plugin/src/main/java/com/github/squi2rel/vp/PaperNativeRuntime.java +++ b/paper-plugin/src/main/java/com/github/squi2rel/vp/PaperNativeRuntime.java @@ -99,6 +99,7 @@ public void stop() { active.set(false); state.set(State.STOPPED); task.cancel(); + NativePackageManager.cancelActiveDownloads(); StreamListener.shutdown(); CURRENT.compareAndSet(this, null); } diff --git a/paper-plugin/src/main/java/com/github/squi2rel/vp/VideoPlayerPaperPlugin.java b/paper-plugin/src/main/java/com/github/squi2rel/vp/VideoPlayerPaperPlugin.java index 44b70c2..7fc24c8 100644 --- a/paper-plugin/src/main/java/com/github/squi2rel/vp/VideoPlayerPaperPlugin.java +++ b/paper-plugin/src/main/java/com/github/squi2rel/vp/VideoPlayerPaperPlugin.java @@ -41,6 +41,7 @@ public final class VideoPlayerPaperPlugin extends JavaPlugin implements Listener public void onEnable() { active = true; FoliaScheduler.initialize(this); + DisplayCleanupService.initialize(this); long epoch = lifecycleEpoch.incrementAndGet(); VideoPlayerMain.version = getPluginMeta().getVersion(); System.setProperty("videoplayer.version", VideoPlayerMain.version); @@ -109,6 +110,7 @@ public void onDisable() { getServer().getMessenger().unregisterIncomingPluginChannel(this, CHANNEL, this); getServer().getMessenger().unregisterOutgoingPluginChannel(this, CHANNEL); VideoPlayerMain.scheduler.shutdownNow(); + DisplayCleanupService.shutdown(); FoliaScheduler.shutdown(this); } diff --git a/paper-plugin/src/main/resources/plugin.yml b/paper-plugin/src/main/resources/plugin.yml index b8c0682..a4afa12 100644 --- a/paper-plugin/src/main/resources/plugin.yml +++ b/paper-plugin/src/main/resources/plugin.yml @@ -1,7 +1,7 @@ name: VideoPlayer main: com.github.squi2rel.vp.VideoPlayerPaperPlugin version: ${version} -api-version: '1.21' +api-version: '${apiVersion}' folia-supported: true softdepend: [Residence] authors: [squi2rel, cloudfl4re] diff --git a/paper-plugin/src/test/java/com/github/squi2rel/vp/DisplayCleanupServiceTest.java b/paper-plugin/src/test/java/com/github/squi2rel/vp/DisplayCleanupServiceTest.java new file mode 100644 index 0000000..66599e0 --- /dev/null +++ b/paper-plugin/src/test/java/com/github/squi2rel/vp/DisplayCleanupServiceTest.java @@ -0,0 +1,73 @@ +package com.github.squi2rel.vp; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DisplayCleanupServiceTest { + @Test + void tracksUntracksAndDispatchesImmutableRecordsByGroup() { + ArrayList dispatched = new ArrayList<>(); + DisplayCleanupService service = new DisplayCleanupService(dispatched::add); + UUID group = UUID.randomUUID(); + DisplayCleanupService.DisplayRecord first = record(UUID.randomUUID()); + DisplayCleanupService.DisplayRecord second = record(UUID.randomUUID()); + + assertTrue(service.trackRecord(group, first)); + assertTrue(service.trackRecord(group, second)); + assertTrue(service.untrackRecord(group, first.entityId())); + assertEquals(1, service.cleanupGroup(group)); + assertEquals(second, dispatched.getFirst()); + assertEquals(0, service.cleanupGroup(group)); + assertEquals(0, service.trackedRecords()); + } + + @Test + void duplicateEntityUpdatesSnapshotWithoutGrowingRegistry() { + ArrayList dispatched = new ArrayList<>(); + DisplayCleanupService service = new DisplayCleanupService(dispatched::add); + UUID group = UUID.randomUUID(); + UUID entityId = UUID.randomUUID(); + DisplayCleanupService.DisplayRecord first = record(entityId); + DisplayCleanupService.DisplayRecord replacement = new DisplayCleanupService.DisplayRecord( + entityId, "minecraft:overworld", 4.0, 5.0, 6.0, "vplight:display" + ); + + assertTrue(service.trackRecord(group, first)); + assertFalse(service.trackRecord(group, replacement)); + assertEquals(1, service.trackedRecords()); + assertEquals(1, service.cleanupGroup(group)); + assertEquals(replacement, dispatched.getFirst()); + } + + @Test + void rejectsIncompleteOrUnboundedRecordData() { + assertThrows(IllegalArgumentException.class, () -> new DisplayCleanupService.DisplayRecord( + null, "minecraft:overworld", 0.0, 0.0, 0.0, "vplight:display" + )); + assertThrows(IllegalArgumentException.class, () -> new DisplayCleanupService.DisplayRecord( + UUID.randomUUID(), "", 0.0, 0.0, 0.0, "vplight:display" + )); + assertThrows(IllegalArgumentException.class, () -> new DisplayCleanupService.DisplayRecord( + UUID.randomUUID(), "minecraft:overworld", Double.NaN, 0.0, 0.0, "vplight:display" + )); + assertThrows(IllegalArgumentException.class, () -> new DisplayCleanupService.DisplayRecord( + UUID.randomUUID(), "minecraft:overworld", 0.0, 0.0, 0.0, "" + )); + assertThrows(IllegalArgumentException.class, () -> new DisplayCleanupService.DisplayRecord( + UUID.randomUUID(), "x".repeat(129), 0.0, 0.0, 0.0, "vplight:display" + )); + } + + private static DisplayCleanupService.DisplayRecord record(UUID entityId) { + return new DisplayCleanupService.DisplayRecord( + entityId, "minecraft:overworld", 1.0, 2.0, 3.0, "vplight:display" + ); + } +} diff --git a/paper-plugin/src/test/java/com/github/squi2rel/vp/FoliaSchedulerTest.java b/paper-plugin/src/test/java/com/github/squi2rel/vp/FoliaSchedulerTest.java index 1001fc4..1b1bb4f 100644 --- a/paper-plugin/src/test/java/com/github/squi2rel/vp/FoliaSchedulerTest.java +++ b/paper-plugin/src/test/java/com/github/squi2rel/vp/FoliaSchedulerTest.java @@ -1,8 +1,12 @@ package com.github.squi2rel.vp; +import org.bukkit.Location; +import org.bukkit.entity.Entity; +import org.bukkit.plugin.Plugin; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; class FoliaSchedulerTest { @Test @@ -12,4 +16,16 @@ void entityAndRegionSchedulingNeverUseZeroTicks() { assertEquals(1L, FoliaScheduler.minimumEntityDelay(1L)); assertEquals(12L, FoliaScheduler.minimumEntityDelay(12L)); } + + @Test + void exposesTaskOwnerOverloadsForDependentPlugins() throws Exception { + assertNotNull(FoliaScheduler.class.getMethod("runGlobal", Plugin.class, Runnable.class)); + assertNotNull(FoliaScheduler.class.getMethod("runGlobalDelayed", Plugin.class, Runnable.class, long.class)); + assertNotNull(FoliaScheduler.class.getMethod("runGlobalFixedRate", Plugin.class, Runnable.class, long.class, long.class)); + assertNotNull(FoliaScheduler.class.getMethod("runAsync", Plugin.class, Runnable.class)); + assertNotNull(FoliaScheduler.class.getMethod("runAtRegionDelayed", Plugin.class, Location.class, Runnable.class, long.class)); + assertNotNull(FoliaScheduler.class.getMethod("runAtRegionFixedRate", Plugin.class, Location.class, Runnable.class, long.class, long.class)); + assertNotNull(FoliaScheduler.class.getMethod("runAtEntityDelayed", Plugin.class, Entity.class, Runnable.class, Runnable.class, long.class)); + assertNotNull(FoliaScheduler.class.getMethod("runAtEntityFixedRate", Plugin.class, Entity.class, Runnable.class, Runnable.class, long.class, long.class)); + } } diff --git a/paper-plugin/src/test/java/com/github/squi2rel/vp/PaperMpvPackagingTest.java b/paper-plugin/src/test/java/com/github/squi2rel/vp/PaperMpvPackagingTest.java new file mode 100644 index 0000000..1524f23 --- /dev/null +++ b/paper-plugin/src/test/java/com/github/squi2rel/vp/PaperMpvPackagingTest.java @@ -0,0 +1,30 @@ +package com.github.squi2rel.vp; + +import org.junit.jupiter.api.Test; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PaperMpvPackagingTest { + private static final String MPV_RESOURCE = "/assets/videoplayer/native/libmpv-windows-x64.zip"; + + @Test + void paperPluginDoesNotBundleMpvRuntime() { + assertNull(PaperMpvPackagingTest.class.getResource(MPV_RESOURCE)); + } + + @Test + void paperPluginKeepsWindowsX64MpvDownloadSource() throws Exception { + try (InputStream input = PaperMpvPackagingTest.class.getResourceAsStream( + "/assets/videoplayer/native-downloads.json")) { + assertNotNull(input); + String json = new String(input.readAllBytes(), StandardCharsets.UTF_8); + assertTrue(json.contains("libmpv-windows-x64.zip")); + assertTrue(json.contains("0a1e614d3b3db315895d19b1e97013fd12da9bc20c50d02d5de3b71a959dfdfb")); + } + } +} diff --git a/paper-plugin/src/test/java/com/github/squi2rel/vp/permission/PluginPermissionDefaultsTest.java b/paper-plugin/src/test/java/com/github/squi2rel/vp/permission/PluginPermissionDefaultsTest.java index 8a9336a..696e690 100644 --- a/paper-plugin/src/test/java/com/github/squi2rel/vp/permission/PluginPermissionDefaultsTest.java +++ b/paper-plugin/src/test/java/com/github/squi2rel/vp/permission/PluginPermissionDefaultsTest.java @@ -28,7 +28,9 @@ void permissionDefaultsMatchPublicAndRestrictedActions() throws IOException { } assertEquals(VideoPermissionAction.values().length + 3, defaults.size()); - String plugin = Files.readString(Path.of("src/main/resources/plugin.yml")); + String plugin = Files.readString(Path.of("src/main/resources/plugin.yml")) + .replace("\r\n", "\n") + .replace('\r', '\n'); assertTrue(plugin.contains(" vlc:\n description: Manage VideoPlayer server notifications.\n usage: /videoplayer:vlc joinmessage\n permission: videoplayer.joinmessage")); assertTrue(plugin.contains(" vlcversion:\n description: Show connected VideoPlayer client versions.\n usage: /vlcversion\n permission: videoplayer.version")); } diff --git a/paper-plugin/src/test/java/com/github/squi2rel/vp/video/IVideoListenerTelemetryTest.java b/paper-plugin/src/test/java/com/github/squi2rel/vp/video/IVideoListenerTelemetryTest.java new file mode 100644 index 0000000..34bf23b --- /dev/null +++ b/paper-plugin/src/test/java/com/github/squi2rel/vp/video/IVideoListenerTelemetryTest.java @@ -0,0 +1,26 @@ +package com.github.squi2rel.vp.video; + +import org.junit.jupiter.api.Test; + +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class IVideoListenerTelemetryTest { + @Test + void defaultTelemetryIsUnsupported() { + IVideoListener listener = new IVideoListener() { + public long getProgress() { return 0; } + public boolean isPlaying() { return false; } + public void playing(Consumer playing) { } + public void stopped(Runnable stopped) { } + public void errored(Runnable errored) { } + public void timeout(Runnable timeout) { } + public void listen() { } + public void cancel() { } + }; + + assertEquals(AudioLevelSnapshot.Status.UNSUPPORTED, listener.audioLevel().status()); + assertEquals(VideoColorSnapshot.Status.UNSUPPORTED, listener.videoColor().status()); + } +} diff --git a/paper-plugin/src/test/java/com/github/squi2rel/vp/video/ListenerShutdownMonitorTest.java b/paper-plugin/src/test/java/com/github/squi2rel/vp/video/ListenerShutdownMonitorTest.java new file mode 100644 index 0000000..6a701a3 --- /dev/null +++ b/paper-plugin/src/test/java/com/github/squi2rel/vp/video/ListenerShutdownMonitorTest.java @@ -0,0 +1,48 @@ +package com.github.squi2rel.vp.video; + +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ListenerShutdownMonitorTest { + @Test + void returnsImmediatelyAndCompletesAfterListenersExit() throws Exception { + AtomicBoolean active = new AtomicBoolean(true); + CountDownLatch completed = new CountDownLatch(1); + + assertTimeoutPreemptively(Duration.ofSeconds(1), () -> + ListenerShutdownMonitor.start("test-listener-shutdown", List.of(active), + AtomicBoolean::get, 1_000L, completed::countDown, ignored -> { + })); + + active.set(false); + assertTrue(completed.await(1, java.util.concurrent.TimeUnit.SECONDS)); + } + + @Test + void reportsTimeoutOnceButStillCompletesWhenListenerEventuallyExits() throws Exception { + AtomicBoolean active = new AtomicBoolean(true); + AtomicLong timedOut = new AtomicLong(); + CountDownLatch timeoutReported = new CountDownLatch(1); + CountDownLatch completed = new CountDownLatch(1); + + ListenerShutdownMonitor.start("test-listener-timeout", List.of(active), + AtomicBoolean::get, 25L, completed::countDown, remaining -> { + timedOut.incrementAndGet(); + timeoutReported.countDown(); + }); + + assertTrue(timeoutReported.await(1, java.util.concurrent.TimeUnit.SECONDS)); + active.set(false); + assertTrue(completed.await(1, java.util.concurrent.TimeUnit.SECONDS)); + assertEquals(1L, timedOut.get()); + } +} diff --git a/paper-plugin/src/test/java/com/github/squi2rel/vp/video/MpvFrameColorParserTest.java b/paper-plugin/src/test/java/com/github/squi2rel/vp/video/MpvFrameColorParserTest.java new file mode 100644 index 0000000..f3f76df --- /dev/null +++ b/paper-plugin/src/test/java/com/github/squi2rel/vp/video/MpvFrameColorParserTest.java @@ -0,0 +1,65 @@ +package com.github.squi2rel.vp.video; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MpvFrameColorParserTest { + @Test + void convertsLimitedBt709BlackAndWhite() { + VideoColorSnapshot black = MpvFrameColorParser.parse(metadata(16, 128, 128), "bt.709", "limited", 10L); + VideoColorSnapshot white = MpvFrameColorParser.parse(metadata(235, 128, 128), "bt.709", "limited", 20L); + + assertEquals(0x000000, black.rgb()); + assertEquals(0f, black.luminance(), 0.001f); + assertEquals(0xFFFFFF, white.rgb()); + assertEquals(1f, white.luminance(), 0.001f); + } + + @Test + void convertsLimitedBt709Red() { + VideoColorSnapshot snapshot = MpvFrameColorParser.parse(metadata(63, 102, 240), "bt.709", "limited", 30L); + + assertEquals(VideoColorSnapshot.Status.AVAILABLE, snapshot.status()); + assertTrue(channel(snapshot.rgb(), 16) >= 245); + assertTrue(channel(snapshot.rgb(), 8) <= 12); + assertTrue(channel(snapshot.rgb(), 0) <= 12); + } + + @Test + void handlesFullRangeAndMapMetadata() { + VideoColorSnapshot snapshot = MpvFrameColorParser.parse( + "{\"lavfi.signalstats.YAVG\":\"128\",\"lavfi.signalstats.UAVG\":\"128\",\"lavfi.signalstats.VAVG\":\"128\"}", + "bt.601", + "full", + 40L + ); + + assertEquals(VideoColorSnapshot.Status.AVAILABLE, snapshot.status()); + assertEquals(128, channel(snapshot.rgb(), 16), 1); + assertEquals(128, channel(snapshot.rgb(), 8), 1); + assertEquals(128, channel(snapshot.rgb(), 0), 1); + assertEquals(40L, snapshot.sampledAtMs()); + } + + @Test + void rejectsMissingAndMalformedMetadata() { + assertEquals(VideoColorSnapshot.Status.WAITING, + MpvFrameColorParser.parse(null, "bt.709", "limited", 1L).status()); + assertEquals(VideoColorSnapshot.Status.WAITING, + MpvFrameColorParser.parse("lavfi.signalstats.YAVG=bad", "bt.709", "limited", 1L).status()); + assertEquals(VideoColorSnapshot.Status.WAITING, + MpvFrameColorParser.parse("lavfi.signalstats.YAVG=16", "bt.709", "limited", 1L).status()); + } + + private static String metadata(double y, double u, double v) { + return "lavfi.signalstats.YAVG=" + y + + "\nlavfi.signalstats.UAVG=" + u + + "\nlavfi.signalstats.VAVG=" + v; + } + + private static int channel(int rgb, int shift) { + return rgb >> shift & 0xFF; + } +} diff --git a/paper-plugin/src/test/java/com/github/squi2rel/vp/video/MpvPendingSeekTest.java b/paper-plugin/src/test/java/com/github/squi2rel/vp/video/MpvPendingSeekTest.java new file mode 100644 index 0000000..8d14948 --- /dev/null +++ b/paper-plugin/src/test/java/com/github/squi2rel/vp/video/MpvPendingSeekTest.java @@ -0,0 +1,23 @@ +package com.github.squi2rel.vp.video; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class MpvPendingSeekTest { + @Test + void preservesLatestProgressUntilConsumedOrSuccessfullySent() { + MpvPendingSeek pending = new MpvPendingSeek(); + + pending.request(12_000L); + pending.request(42_000L); + assertEquals(42_000L, pending.peek()); + assertEquals(42_000L, pending.consume()); + assertEquals(-1L, pending.consume()); + + pending.request(-5L); + assertEquals(0L, pending.peek()); + pending.clearIf(0L); + assertEquals(-1L, pending.peek()); + } +} diff --git a/paper-plugin/src/test/java/com/github/squi2rel/vp/video/MpvTelemetryFreshnessTest.java b/paper-plugin/src/test/java/com/github/squi2rel/vp/video/MpvTelemetryFreshnessTest.java new file mode 100644 index 0000000..56c6ed3 --- /dev/null +++ b/paper-plugin/src/test/java/com/github/squi2rel/vp/video/MpvTelemetryFreshnessTest.java @@ -0,0 +1,33 @@ +package com.github.squi2rel.vp.video; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class MpvTelemetryFreshnessTest { + @Test + void identicalAudioMetadataRefreshesTimestamp() { + String metadata = "lavfi.astats.Overall.RMS_level=-20\nlavfi.astats.Overall.Peak_level=-8"; + AudioLevelSnapshot first = MpvStreamListener.updateAudioSnapshot(AudioLevelSnapshot.waiting(), metadata, 100L); + AudioLevelSnapshot second = MpvStreamListener.updateAudioSnapshot(first, metadata, 200L); + + assertEquals(AudioLevelSnapshot.Status.AVAILABLE, second.status()); + assertEquals(-8f, second.peakDb()); + assertEquals(200L, second.sampledAtMs()); + } + + @Test + void identicalColorMetadataRefreshesTimestamp() { + String metadata = "lavfi.signalstats.YAVG=63\nlavfi.signalstats.UAVG=102\nlavfi.signalstats.VAVG=240"; + VideoColorSnapshot first = MpvStreamListener.updateColorSnapshot( + VideoColorSnapshot.waiting(), metadata, "bt.709", "limited", 100L + ); + VideoColorSnapshot second = MpvStreamListener.updateColorSnapshot( + first, metadata, "bt.709", "limited", 200L + ); + + assertEquals(VideoColorSnapshot.Status.AVAILABLE, second.status()); + assertEquals(first.rgb(), second.rgb()); + assertEquals(200L, second.sampledAtMs()); + } +} diff --git a/paper-plugin/src/test/java/com/github/squi2rel/vp/video/MpvTelemetryIntegrationTest.java b/paper-plugin/src/test/java/com/github/squi2rel/vp/video/MpvTelemetryIntegrationTest.java new file mode 100644 index 0000000..2785a59 --- /dev/null +++ b/paper-plugin/src/test/java/com/github/squi2rel/vp/video/MpvTelemetryIntegrationTest.java @@ -0,0 +1,102 @@ +package com.github.squi2rel.vp.video; + +import com.github.squi2rel.vp.NativeDownloadConfig; +import com.github.squi2rel.vp.NativePackageManager; +import com.sun.jna.Memory; +import com.sun.jna.Native; +import com.sun.jna.Pointer; +import com.sun.jna.ptr.PointerByReference; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; + +import static com.github.squi2rel.vp.video.MpvLibrary.MPV_FORMAT_STRING; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@EnabledIfEnvironmentVariable(named = "VPLIGHT_SAMPLE", matches = ".+") +class MpvTelemetryIntegrationTest { + @Test + void readsAudioAndVideoTelemetryFromRealMedia() { + System.setProperty("videoplayer.configDir", System.getenv("VPLIGHT_NATIVE_DIR")); + NativePackageManager.selectPlatform(NativePackageManager.BACKEND_MPV, "windows_x64"); + NativeDownloadConfig downloads = NativeDownloadConfig.load(); + NativePackageManager.DownloadResult installed = NativePackageManager.downloadAndInstall( + NativePackageManager.BACKEND_MPV, + "windows_x64", + downloads.sources(NativePackageManager.BACKEND_MPV, "windows_x64"), + null + ); + assertTrue(installed.success(), () -> String.valueOf(installed.error())); + MpvLibrary.LibMpv lib = MpvLibrary.get(); + Pointer context = lib.mpv_create(); + if (context == null) throw new IllegalStateException("mpv_create returned null"); + try { + option(lib, context, "config", "no"); + option(lib, context, "terminal", "no"); + option(lib, context, "vo", "null"); + option(lib, context, "ao", "null"); + option(lib, context, "mute", "yes"); + option(lib, context, "af", "@videoplayer_audio_meter:lavfi=[astats=metadata=1:reset=1]"); + option(lib, context, "vf", "@videoplayer_color_meter:lavfi=[fps=10,scale=32:18:flags=area,format=pix_fmts=yuv444p,signalstats]"); + check(lib, lib.mpv_initialize(context)); + command(lib, context, "loadfile", Path.of(System.getenv("VPLIGHT_SAMPLE")).toAbsolutePath().toString(), "replace"); + AudioLevelSnapshot audio = AudioLevelSnapshot.waiting(); + VideoColorSnapshot color = VideoColorSnapshot.waiting(); + long deadline = System.currentTimeMillis() + 30_000; + while (System.currentTimeMillis() < deadline + && (audio.status() != AudioLevelSnapshot.Status.AVAILABLE + || color.status() != VideoColorSnapshot.Status.AVAILABLE)) { + lib.mpv_wait_event(context, 0.1); + long now = System.currentTimeMillis(); + String audioMetadata = string(lib, context, "af-metadata/videoplayer_audio_meter"); + String colorMetadata = string(lib, context, "vf-metadata/videoplayer_color_meter"); + if (audioMetadata != null) audio = MpvAudioLevelParser.parse(audioMetadata, now); + if (colorMetadata != null) { + color = MpvFrameColorParser.parse(colorMetadata, + string(lib, context, "video-params/colormatrix"), + string(lib, context, "video-params/colorlevels"), now); + } + } + assertEquals(AudioLevelSnapshot.Status.AVAILABLE, audio.status()); + assertEquals(VideoColorSnapshot.Status.AVAILABLE, color.status()); + } finally { + lib.mpv_terminate_destroy(context); + } + } + + private static void option(MpvLibrary.LibMpv lib, Pointer context, String name, String value) { + check(lib, lib.mpv_set_option_string(context, name, value)); + } + + private static void command(MpvLibrary.LibMpv lib, Pointer context, String... values) { + ArrayList strings = new ArrayList<>(values.length); + Memory arguments = new Memory((long) (values.length + 1) * Native.POINTER_SIZE); + for (int index = 0; index < values.length; index++) { + Memory value = MpvLibrary.utf8(values[index]); + strings.add(value); + arguments.setPointer((long) index * Native.POINTER_SIZE, value); + } + arguments.setPointer((long) values.length * Native.POINTER_SIZE, null); + check(lib, lib.mpv_command(context, arguments)); + } + + private static String string(MpvLibrary.LibMpv lib, Pointer context, String name) { + PointerByReference reference = new PointerByReference(); + if (lib.mpv_get_property(context, name, MPV_FORMAT_STRING, reference.getPointer()) < 0) return null; + Pointer value = reference.getValue(); + if (value == null) return null; + try { + return value.getString(0, StandardCharsets.UTF_8.name()); + } finally { + lib.mpv_free(value); + } + } + + private static void check(MpvLibrary.LibMpv lib, int result) { + if (result < 0) throw new IllegalStateException(lib.mpv_error_string(result)); + } +} diff --git a/paper-plugin/src/test/java/com/github/squi2rel/vp/video/MpvTelemetryPermitPoolTest.java b/paper-plugin/src/test/java/com/github/squi2rel/vp/video/MpvTelemetryPermitPoolTest.java new file mode 100644 index 0000000..a7208d5 --- /dev/null +++ b/paper-plugin/src/test/java/com/github/squi2rel/vp/video/MpvTelemetryPermitPoolTest.java @@ -0,0 +1,29 @@ +package com.github.squi2rel.vp.video; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MpvTelemetryPermitPoolTest { + @Test + void boundsConcurrentTelemetryDecodersAndReusesReleasedSlots() { + MpvTelemetryPermitPool pool = new MpvTelemetryPermitPool(2); + + assertTrue(pool.acquire()); + assertTrue(pool.acquire()); + assertFalse(pool.acquire()); + assertEquals(0, pool.available()); + + pool.release(); + assertTrue(pool.acquire()); + assertEquals(0, pool.available()); + } + + @Test + void rejectsInvalidCapacity() { + org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class, + () -> new MpvTelemetryPermitPool(0)); + } +} diff --git a/paper-plugin/src/test/java/com/github/squi2rel/vp/video/PlaybackTelemetryControllerTest.java b/paper-plugin/src/test/java/com/github/squi2rel/vp/video/PlaybackTelemetryControllerTest.java new file mode 100644 index 0000000..995cf10 --- /dev/null +++ b/paper-plugin/src/test/java/com/github/squi2rel/vp/video/PlaybackTelemetryControllerTest.java @@ -0,0 +1,109 @@ +package com.github.squi2rel.vp.video; + +import com.github.squi2rel.vp.provider.VideoInfo; +import org.joml.Vector3f; +import org.junit.jupiter.api.Test; + +import java.util.UUID; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PlaybackTelemetryControllerTest { + @Test + void telemetryRequestAttachesProbeWithoutReplacingPlaybackAuthority() { + VideoArea area = new VideoArea(new Vector3f(), new Vector3f(1), "area", "world"); + area.initServer(); + area.addPlayer(UUID.randomUUID()); + VideoScreen screen = new VideoScreen(area, "telemetry", new Vector3f(), new Vector3f(1, 0, 0), + new Vector3f(1, 1, 0), new Vector3f(0, 1, 0), ""); + PlaybackQueue queue = new PlaybackQueue(screen); + FakeListener playback = new FakeListener(); + FakeListener probe = new FakeListener(); + PlaybackController controller = new PlaybackController( + screen, + queue, + new ScreenBroadcaster(screen), + (info, settings) -> info, + (url, settings) -> null, + info -> new TelemetryVideoListener(playback, null), + Runnable::run, + Runnable::run, + (command, delay) -> command.run() + ); + VideoInfo item = new VideoInfo("player", "telemetry", "https://example.com/telemetry.mp4", "", + -1, true, new String[0], 1_000L); + queue.add(item); + controller.playNext(); + + controller.applyTelemetryRequest(true, ignored -> probe); + probe.fail(); + + assertSame(item, controller.currentInfo()); + assertTrue(playback.isPlaying()); + assertFalse(probe.isPlaying()); + + controller.applyTelemetryRequest(false, ignored -> { + throw new AssertionError("detach must not create a probe"); + }); + assertFalse(probe.isPlaying()); + } + + private static final class FakeListener implements IVideoListener { + private boolean playing; + private Consumer playingCallback = ignored -> { + }; + private Runnable stoppedCallback = () -> { + }; + private Runnable errorCallback = () -> { + }; + + @Override + public long getProgress() { + return 0L; + } + + @Override + public boolean isPlaying() { + return playing; + } + + @Override + public void playing(Consumer playing) { + playingCallback = playing; + } + + @Override + public void stopped(Runnable stopped) { + stoppedCallback = stopped; + } + + @Override + public void errored(Runnable errored) { + errorCallback = errored; + } + + @Override + public void timeout(Runnable timeout) { + } + + @Override + public void listen() { + playing = true; + playingCallback.accept(true); + } + + @Override + public void cancel() { + playing = false; + } + + private void fail() { + playing = false; + errorCallback.run(); + stoppedCallback.run(); + } + } +} diff --git a/paper-plugin/src/test/java/com/github/squi2rel/vp/video/PlaybackTelemetryRegistryTest.java b/paper-plugin/src/test/java/com/github/squi2rel/vp/video/PlaybackTelemetryRegistryTest.java new file mode 100644 index 0000000..3b7a6b8 --- /dev/null +++ b/paper-plugin/src/test/java/com/github/squi2rel/vp/video/PlaybackTelemetryRegistryTest.java @@ -0,0 +1,86 @@ +package com.github.squi2rel.vp.video; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PlaybackTelemetryRegistryTest { + @Test + void referenceCountsAndClosesIdempotently() { + ScreenKey key = new ScreenKey("minecraft:overworld", "area", "screen"); + PlaybackTelemetryRegistry.Registration first = PlaybackTelemetryRegistry.acquire(key); + PlaybackTelemetryRegistry.Registration second = PlaybackTelemetryRegistry.acquire(key); + + assertEquals(1, PlaybackTelemetryRegistry.apiVersion()); + assertTrue(PlaybackTelemetryRegistry.requested(key)); + first.close(); + first.close(); + assertTrue(PlaybackTelemetryRegistry.requested(key)); + second.close(); + assertFalse(PlaybackTelemetryRegistry.requested(key)); + } + + @Test + void rejectsIncompleteScreenKeys() { + assertThrows(IllegalArgumentException.class, + () -> PlaybackTelemetryRegistry.acquire(new ScreenKey("", "area", "screen"))); + assertThrows(IllegalArgumentException.class, + () -> PlaybackTelemetryRegistry.acquire(new ScreenKey(null, "area", "screen"))); + } + + @Test + void bindingReceivesOnlyReferenceCountTransitions() { + ScreenKey key = new ScreenKey("minecraft:overworld", "area", "transitions"); + ArrayList states = new ArrayList<>(); + PlaybackTelemetryRegistry.Binding binding = PlaybackTelemetryRegistry.bind(key, states::add); + + PlaybackTelemetryRegistry.Registration first = PlaybackTelemetryRegistry.acquire(key); + PlaybackTelemetryRegistry.Registration second = PlaybackTelemetryRegistry.acquire(key); + first.close(); + second.close(); + binding.close(); + + assertEquals(List.of(false, true, false), states); + } + + @Test + void bindingCreatedAfterAcquireReceivesCurrentStateAndStopsAfterClose() { + ScreenKey key = new ScreenKey("minecraft:overworld", "area", "late-binding"); + ArrayList states = new ArrayList<>(); + PlaybackTelemetryRegistry.Registration registration = PlaybackTelemetryRegistry.acquire(key); + PlaybackTelemetryRegistry.Binding binding = PlaybackTelemetryRegistry.bind(key, states::add); + + binding.close(); + registration.close(); + + assertEquals(List.of(true), states); + } + + @Test + void requestedScreenKeySpaceIsBoundedAndReleasedKeysCanBeReused() { + ArrayList registrations = new ArrayList<>(); + try { + for (int i = 0; i < PlaybackTelemetryRegistry.MAX_REQUESTED_SCREENS; i++) { + registrations.add(PlaybackTelemetryRegistry.acquire( + new ScreenKey("minecraft:overworld", "bounded", "screen-" + i) + )); + } + assertThrows(IllegalStateException.class, () -> PlaybackTelemetryRegistry.acquire( + new ScreenKey("minecraft:overworld", "bounded", "overflow") + )); + } finally { + registrations.forEach(PlaybackTelemetryRegistry.Registration::close); + } + + PlaybackTelemetryRegistry.Registration reused = PlaybackTelemetryRegistry.acquire( + new ScreenKey("minecraft:overworld", "bounded", "reused") + ); + reused.close(); + } +} diff --git a/paper-plugin/src/test/java/com/github/squi2rel/vp/video/StreamListenerTelemetryBackendTest.java b/paper-plugin/src/test/java/com/github/squi2rel/vp/video/StreamListenerTelemetryBackendTest.java new file mode 100644 index 0000000..3f937b6 --- /dev/null +++ b/paper-plugin/src/test/java/com/github/squi2rel/vp/video/StreamListenerTelemetryBackendTest.java @@ -0,0 +1,26 @@ +package com.github.squi2rel.vp.video; + +import com.github.squi2rel.vp.NativePackageManager; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class StreamListenerTelemetryBackendTest { + @Test + void telemetryPrefersMpvEvenWhenVlcIsConfigured() { + assertEquals(NativePackageManager.BACKEND_MPV, + StreamListener.selectBackend(true, NativePackageManager.BACKEND_VLC, true, true)); + } + + @Test + void normalPlaybackKeepsConfiguredVlcPreference() { + assertEquals(NativePackageManager.BACKEND_VLC, + StreamListener.selectBackend(false, NativePackageManager.BACKEND_VLC, true, true)); + } + + @Test + void telemetryFallsBackToVlcWhenMpvIsUnavailable() { + assertEquals(NativePackageManager.BACKEND_VLC, + StreamListener.selectBackend(true, NativePackageManager.BACKEND_VLC, false, true)); + } +} diff --git a/paper-plugin/src/test/java/com/github/squi2rel/vp/video/TelemetryVideoListenerTest.java b/paper-plugin/src/test/java/com/github/squi2rel/vp/video/TelemetryVideoListenerTest.java new file mode 100644 index 0000000..407a615 --- /dev/null +++ b/paper-plugin/src/test/java/com/github/squi2rel/vp/video/TelemetryVideoListenerTest.java @@ -0,0 +1,199 @@ +package com.github.squi2rel.vp.video; + +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TelemetryVideoListenerTest { + @Test + void telemetryCallbacksNeverControlPlayback() { + FakeListener playback = new FakeListener(); + FakeListener telemetry = new FakeListener(); + TelemetryVideoListener listener = new TelemetryVideoListener(playback, telemetry); + AtomicInteger playing = new AtomicInteger(); + AtomicInteger stopped = new AtomicInteger(); + AtomicInteger errored = new AtomicInteger(); + AtomicInteger timedOut = new AtomicInteger(); + listener.playing(ignored -> playing.incrementAndGet()); + listener.stopped(stopped::incrementAndGet); + listener.errored(errored::incrementAndGet); + listener.timeout(timedOut::incrementAndGet); + + listener.listen(); + telemetry.firePlaying(); + telemetry.fireStopped(); + telemetry.fireErrored(); + telemetry.fireTimeout(); + + assertEquals(0, playing.get()); + assertEquals(0, stopped.get()); + assertEquals(0, errored.get()); + assertEquals(0, timedOut.get()); + + playback.firePlaying(); + playback.fireStopped(); + playback.fireErrored(); + playback.fireTimeout(); + + assertEquals(1, playing.get()); + assertEquals(1, stopped.get()); + assertEquals(1, errored.get()); + assertEquals(1, timedOut.get()); + } + + @Test + void dynamicallyAttachedTelemetryStartsAtPlaybackProgress() { + FakeListener playback = new FakeListener(); + playback.progress = 42_000L; + FakeListener telemetry = new FakeListener(); + TelemetryVideoListener listener = new TelemetryVideoListener(playback, null); + + listener.listen(); + assertTrue(listener.attachTelemetry(telemetry)); + + assertEquals(1, telemetry.listenCount); + assertEquals(42_000L, telemetry.progress); + assertSame(telemetry.audio, listener.audioLevel()); + assertSame(telemetry.color, listener.videoColor()); + assertTrue(listener.detachTelemetry()); + assertTrue(telemetry.cancelled); + assertEquals(AudioLevelSnapshot.Status.UNSUPPORTED, listener.audioLevel().status()); + assertEquals(VideoColorSnapshot.Status.UNSUPPORTED, listener.videoColor().status()); + } + + @Test + void cancellationReleasesPlaybackAndTelemetry() { + FakeListener playback = new FakeListener(); + FakeListener telemetry = new FakeListener(); + TelemetryVideoListener listener = new TelemetryVideoListener(playback, telemetry); + + listener.listen(); + listener.cancel(); + + assertTrue(playback.cancelled); + assertTrue(telemetry.cancelled); + assertFalse(listener.isPlaying()); + } + + @Test + void telemetryErrorImmediatelyBecomesUnsupported() { + FakeListener playback = new FakeListener(); + FakeListener telemetry = new FakeListener(); + TelemetryVideoListener listener = new TelemetryVideoListener(playback, telemetry); + + listener.listen(); + telemetry.fireErrored(); + + assertTrue(telemetry.cancelled); + assertEquals(AudioLevelSnapshot.Status.UNSUPPORTED, listener.audioLevel().status()); + assertEquals(VideoColorSnapshot.Status.UNSUPPORTED, listener.videoColor().status()); + } + + @Test + void telemetryTimeoutImmediatelyBecomesUnsupported() { + FakeListener playback = new FakeListener(); + FakeListener telemetry = new FakeListener(); + TelemetryVideoListener listener = new TelemetryVideoListener(playback, telemetry); + + listener.listen(); + telemetry.fireTimeout(); + + assertTrue(telemetry.cancelled); + assertEquals(AudioLevelSnapshot.Status.UNSUPPORTED, listener.audioLevel().status()); + assertEquals(VideoColorSnapshot.Status.UNSUPPORTED, listener.videoColor().status()); + } + + private static final class FakeListener implements IVideoListener { + private final AudioLevelSnapshot audio = AudioLevelSnapshot.available(-20f, -8f, 10L); + private final VideoColorSnapshot color = VideoColorSnapshot.available(0x123456, 0.4f, 10L); + private Consumer playing = ignored -> { + }; + private Runnable stopped = () -> { + }; + private Runnable errored = () -> { + }; + private Runnable timeout = () -> { + }; + private long progress; + private int listenCount; + private boolean cancelled; + + @Override + public long getProgress() { + return progress; + } + + @Override + public void setProgress(long progress) { + this.progress = progress; + } + + @Override + public boolean isPlaying() { + return listenCount > 0 && !cancelled; + } + + @Override + public void playing(Consumer playing) { + this.playing = playing; + } + + @Override + public void stopped(Runnable stopped) { + this.stopped = stopped; + } + + @Override + public void errored(Runnable errored) { + this.errored = errored; + } + + @Override + public void timeout(Runnable timeout) { + this.timeout = timeout; + } + + @Override + public AudioLevelSnapshot audioLevel() { + return audio; + } + + @Override + public VideoColorSnapshot videoColor() { + return color; + } + + @Override + public void listen() { + listenCount++; + cancelled = false; + } + + @Override + public void cancel() { + cancelled = true; + } + + private void firePlaying() { + playing.accept(true); + } + + private void fireStopped() { + stopped.run(); + } + + private void fireErrored() { + errored.run(); + } + + private void fireTimeout() { + timeout.run(); + } + } +} diff --git a/paper-plugin/src/test/java/com/github/squi2rel/vp/video/VideoColorSnapshotTest.java b/paper-plugin/src/test/java/com/github/squi2rel/vp/video/VideoColorSnapshotTest.java new file mode 100644 index 0000000..d53da61 --- /dev/null +++ b/paper-plugin/src/test/java/com/github/squi2rel/vp/video/VideoColorSnapshotTest.java @@ -0,0 +1,24 @@ +package com.github.squi2rel.vp.video; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class VideoColorSnapshotTest { + @Test + void clampsAvailableValues() { + VideoColorSnapshot snapshot = VideoColorSnapshot.available(0x1FFFFFF, 1.5f, -2L); + + assertEquals(VideoColorSnapshot.Status.AVAILABLE, snapshot.status()); + assertEquals(0xFFFFFF, snapshot.rgb()); + assertEquals(1f, snapshot.luminance()); + assertEquals(0L, snapshot.sampledAtMs()); + } + + @Test + void exposesUnavailableStates() { + assertEquals(VideoColorSnapshot.Status.WAITING, VideoColorSnapshot.waiting().status()); + assertEquals(VideoColorSnapshot.Status.NO_VIDEO, VideoColorSnapshot.noVideo().status()); + assertEquals(VideoColorSnapshot.Status.UNSUPPORTED, VideoColorSnapshot.unsupported().status()); + } +} diff --git a/paper-plugin/src/test/java/com/github/squi2rel/vp/video/VideoListenersTelemetryTest.java b/paper-plugin/src/test/java/com/github/squi2rel/vp/video/VideoListenersTelemetryTest.java new file mode 100644 index 0000000..83016bc --- /dev/null +++ b/paper-plugin/src/test/java/com/github/squi2rel/vp/video/VideoListenersTelemetryTest.java @@ -0,0 +1,86 @@ +package com.github.squi2rel.vp.video; + +import com.github.squi2rel.vp.provider.VideoInfo; +import org.joml.Vector3f; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class VideoListenersTelemetryTest { + @Test + void registeredKnownDurationScreenRequiresNativeTelemetry() { + VideoArea area = new VideoArea(new Vector3f(), new Vector3f(10, 10, 10), "area", "minecraft:overworld"); + VideoScreen screen = new VideoScreen(area, "screen", new Vector3f(), new Vector3f(0, 1, 0), + new Vector3f(1, 1, 0), new Vector3f(1, 0, 0), ""); + VideoInfo knownDuration = new VideoInfo("player", "video", "https://example.com/video.mp4", "", -1, + true, new String[0], 60_000L); + + assertFalse(VideoListeners.requiresNativeTelemetry(screen, knownDuration)); + try (PlaybackTelemetryRegistry.Registration ignored = PlaybackTelemetryRegistry.acquire(ScreenKey.of(screen))) { + assertTrue(VideoListeners.requiresNativeTelemetry(screen, knownDuration)); + } + assertFalse(VideoListeners.requiresNativeTelemetry(screen, knownDuration)); + } + + @Test + void pathlessMediaNeverRequiresNativeTelemetry() { + VideoArea area = new VideoArea(new Vector3f(), new Vector3f(10, 10, 10), "area", "minecraft:overworld"); + VideoScreen screen = new VideoScreen(area, "screen", new Vector3f(), new Vector3f(0, 1, 0), + new Vector3f(1, 1, 0), new Vector3f(1, 0, 0), ""); + VideoInfo pathless = new VideoInfo("player", "video", "", "https://example.com/watch", -1, + true, new String[0], 0L); + + try (PlaybackTelemetryRegistry.Registration ignored = PlaybackTelemetryRegistry.acquire(ScreenKey.of(screen))) { + assertFalse(VideoListeners.requiresNativeTelemetry(screen, pathless)); + } + } + + @Test + void unavailableTelemetryKeepsKnownDurationPlaybackListener() { + VideoArea area = new VideoArea(new Vector3f(), new Vector3f(10, 10, 10), "area", "minecraft:overworld"); + VideoScreen screen = new VideoScreen(area, "screen", new Vector3f(), new Vector3f(0, 1, 0), + new Vector3f(1, 1, 0), new Vector3f(1, 0, 0), ""); + VideoInfo knownDuration = new VideoInfo("player", "video", "https://example.com/video.mp4", "", -1, + true, new String[0], 60_000L); + AtomicBoolean factoryCalled = new AtomicBoolean(); + AtomicBoolean playing = new AtomicBoolean(); + + try (PlaybackTelemetryRegistry.Registration ignored = PlaybackTelemetryRegistry.acquire(ScreenKey.of(screen))) { + IVideoListener listener = VideoListeners.from(screen, knownDuration, info -> { + factoryCalled.set(true); + return null; + }); + assertNotNull(listener); + listener.playing(playing::set); + listener.listen(); + listener.cancel(); + } + + assertTrue(factoryCalled.get()); + assertTrue(playing.get()); + } + + @Test + void eligiblePlaybackIsPreparedForLateTelemetryWithoutStartingProbe() { + VideoArea area = new VideoArea(new Vector3f(), new Vector3f(10, 10, 10), "area", "minecraft:overworld"); + VideoScreen screen = new VideoScreen(area, "late", new Vector3f(), new Vector3f(0, 1, 0), + new Vector3f(1, 1, 0), new Vector3f(1, 0, 0), ""); + VideoInfo knownDuration = new VideoInfo("player", "video", "https://example.com/video.mp4", "", -1, + true, new String[0], 60_000L); + AtomicBoolean factoryCalled = new AtomicBoolean(); + + IVideoListener listener = VideoListeners.from(screen, knownDuration, info -> { + factoryCalled.set(true); + return null; + }); + + assertInstanceOf(TelemetryVideoListener.class, listener); + assertFalse(factoryCalled.get()); + listener.cancel(); + } +} diff --git a/paper-plugin/src/test/java/com/github/squi2rel/vp/video/VlcStreamListenerShutdownTest.java b/paper-plugin/src/test/java/com/github/squi2rel/vp/video/VlcStreamListenerShutdownTest.java index 5797dc7..b75fb7c 100644 --- a/paper-plugin/src/test/java/com/github/squi2rel/vp/video/VlcStreamListenerShutdownTest.java +++ b/paper-plugin/src/test/java/com/github/squi2rel/vp/video/VlcStreamListenerShutdownTest.java @@ -6,9 +6,14 @@ import org.mockito.MockedStatic; import java.lang.reflect.Field; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mockStatic; class VlcStreamListenerShutdownTest { @@ -23,21 +28,23 @@ void tearDown() throws Exception { @Test void shutdownReleasesGlobalInstanceAndResetsLoadState() throws Exception { Pointer instance = new Pointer(17L); + AtomicReference released = new AtomicReference<>(); + CountDownLatch releaseCalled = new CountDownLatch(1); set("instance", instance); set("loadError", new IllegalStateException("stale")); set("loadAttempted", true); - try (MockedStatic library = mockStatic(VlcLibrary.class)) { - VlcStreamListener.shutdown(); - - library.verify(() -> VlcLibrary.releaseInstance(instance)); - library.verify(VlcLibrary::resetLoadState); - } + VlcStreamListener.shutdown(pointer -> { + released.set(pointer); + releaseCalled.countDown(); + }); assertNull(get("instance")); assertNull(get("loadError")); assertFalse((boolean) get("loadAttempted")); assertFalse(VlcStreamListener.load()); + assertTrue(releaseCalled.await(1, TimeUnit.SECONDS)); + assertSame(instance, released.get()); try (MockedStatic library = mockStatic(VlcLibrary.class)) { VlcStreamListener.resetLoadState(); @@ -46,6 +53,18 @@ void shutdownReleasesGlobalInstanceAndResetsLoadState() throws Exception { assertFalse((boolean) get("shutDown")); } + @Test + void releaseInstanceReleasesNativeState() { + Pointer instance = new Pointer(17L); + + try (MockedStatic library = mockStatic(VlcLibrary.class)) { + VlcStreamListener.releaseInstance(instance); + + library.verify(() -> VlcLibrary.releaseInstance(instance)); + library.verify(VlcLibrary::resetLoadState); + } + } + private static Object get(String name) throws Exception { Field field = VlcStreamListener.class.getDeclaredField(name); field.setAccessible(true); diff --git a/settings.gradle b/settings.gradle index 13e4c0e..c2d9538 100644 --- a/settings.gradle +++ b/settings.gradle @@ -9,7 +9,15 @@ pluginManagement { } } +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0' +} + rootProject.name = "VideoPlayer" include "paper-plugin" +include "paper-plugin-26.2" +include "fabric-1.21.11" +include "fabric-26.2" include "mcng-core" include "mcng-fabric-client" +include "mcng-fabric-client-26.2" diff --git a/src/client/java/com/github/squi2rel/vp/CameraRenderer.java b/src/client/java/com/github/squi2rel/vp/CameraRenderer.java index bb28cb2..827ecca 100644 --- a/src/client/java/com/github/squi2rel/vp/CameraRenderer.java +++ b/src/client/java/com/github/squi2rel/vp/CameraRenderer.java @@ -1,9 +1,9 @@ package com.github.squi2rel.vp; import com.github.squi2rel.vp.mixin.client.MinecraftClientAccessor; -import net.minecraft.client.MinecraftClient; -import net.minecraft.client.gl.Framebuffer; -import net.minecraft.entity.Entity; +import com.mojang.blaze3d.pipeline.RenderTarget; +import net.minecraft.client.Minecraft; +import net.minecraft.world.entity.Entity; public final class CameraRenderer { public static boolean rendering; @@ -14,20 +14,20 @@ public final class CameraRenderer { private CameraRenderer() { } - public static void renderWorld(Entity entity, Framebuffer framebuffer, int cameraFov) { - MinecraftClient client = MinecraftClient.getInstance(); - if (client.world == null || entity == null || framebuffer == null || rendering) return; + public static void renderWorld(Entity entity, RenderTarget framebuffer, int cameraFov) { + Minecraft client = Minecraft.getInstance(); + if (client.level == null || entity == null || framebuffer == null || rendering) return; MinecraftClientAccessor access = (MinecraftClientAccessor) client; - Framebuffer oldFramebuffer = access.videoplayer$getFramebuffer(); + RenderTarget oldFramebuffer = access.videoplayer$getFramebuffer(); Entity oldCamera = client.getCameraEntity(); - width = framebuffer.textureWidth; - height = framebuffer.textureHeight; + width = framebuffer.width; + height = framebuffer.height; fov = Math.clamp(cameraFov, 1, 179); rendering = true; try { access.videoplayer$setFramebuffer(framebuffer); client.setCameraEntity(entity); - client.gameRenderer.renderWorld(client.getRenderTickCounter()); + client.gameRenderer.renderLevel(client.getDeltaTracker()); } finally { client.setCameraEntity(oldCamera); access.videoplayer$setFramebuffer(oldFramebuffer); diff --git a/src/client/java/com/github/squi2rel/vp/ClientPacketHandler.java b/src/client/java/com/github/squi2rel/vp/ClientPacketHandler.java index c961adf..2ec43d9 100644 --- a/src/client/java/com/github/squi2rel/vp/ClientPacketHandler.java +++ b/src/client/java/com/github/squi2rel/vp/ClientPacketHandler.java @@ -36,10 +36,9 @@ import net.fabricmc.fabric.api.client.command.v2.ClientCommandManager; import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; -import net.minecraft.client.MinecraftClient; -import net.minecraft.client.network.ClientPlayerEntity; -import net.minecraft.text.Text; -import net.minecraft.util.Formatting; +import net.minecraft.ChatFormatting; +import net.minecraft.client.Minecraft; +import net.minecraft.client.player.LocalPlayer; import org.joml.Vector3f; import java.util.ArrayList; @@ -126,7 +125,7 @@ public static void handle(ByteBuf buf, long receivedAt) { if (!screen.acceptServerPlaybackGeneration(generation)) return; IVideoPlayer player = screen.player; screen.clearPlaybackState(); - if (player != null) MinecraftClient.getInstance().execute(player::stop); + if (player != null) Minecraft.getInstance().execute(player::stop); } case EXECUTE -> handleExecute(buf); case IDLE_PLAY -> { @@ -242,8 +241,8 @@ private static void handleRequestResult(ByteBuf buf) { } if ((status == RequestResultStatus.ERROR || status == RequestResultStatus.DENIED) && message != null && !message.isEmpty()) { - ClientPlayerEntity player = MinecraftClient.getInstance().player; - if (player != null) player.sendMessage(VpTexts.text(message).copy().formatted(Formatting.RED), false); + LocalPlayer player = Minecraft.getInstance().player; + if (player != null) player.displayClientMessage(VpTexts.text(message).copy().withStyle(ChatFormatting.RED), false); } if (pending.callback() != null) { pending.callback().accept(new RequestResult(requestId, status, message)); @@ -263,9 +262,9 @@ private static void handlePlaybackNotice(ByteBuf buf) { VideoPackets.readName(buf); boolean error = buf.readBoolean(); VpTranslation message = VideoPackets.readTranslation(buf); - ClientPlayerEntity player = MinecraftClient.getInstance().player; + LocalPlayer player = Minecraft.getInstance().player; if (player != null && message != null && !message.isEmpty()) { - player.sendMessage(VpTexts.text(message).copy().formatted(error ? Formatting.RED : Formatting.YELLOW), false); + player.displayClientMessage(VpTexts.text(message).copy().withStyle(error ? ChatFormatting.RED : ChatFormatting.YELLOW), false); } } @@ -350,7 +349,7 @@ private static void handleRequest(ByteBuf buf, long receivedAt) { boolean idle = buf.readBoolean(); ClientVideoScreen screen = screenOrNull(areaName, screenName); if (screen == null) return; - ClientPlayerEntity player = MinecraftClient.getInstance().player; + LocalPlayer player = Minecraft.getInstance().player; if (player == null) return; int playbackToken = screen.beginServerPlaybackRequest(generation); if (playbackToken < 0) return; @@ -360,17 +359,17 @@ private static void handleRequest(ByteBuf buf, long receivedAt) { screen.trackPlaybackFuture(playbackToken, video); video.whenComplete((v, error) -> { if (error != null || v == null) { - MinecraftClient.getInstance().execute(() -> { + Minecraft.getInstance().execute(() -> { if (screen.canAcceptPlayback(playbackToken)) { screen.setServerPlaybackResolution(generation, null); reportClientPlaybackResolution(screen, info, generation, null); screen.failPlaybackRequest(playbackToken); - player.sendMessage(VpTexts.tr("message.videoplayer.source_unresolved", "Unable to resolve video source"), false); + player.displayClientMessage(VpTexts.tr("message.videoplayer.source_unresolved", "Unable to resolve video source"), false); } }); return; } - MinecraftClient.getInstance().execute(() -> { + Minecraft.getInstance().execute(() -> { if (!screen.canAcceptPlayback(playbackToken)) return; if (v.seekable() && progress >= 0) { long transport = Math.max(0L, receivedAt - serverSentAt); @@ -428,6 +427,7 @@ static CompletableFuture resolveForLocalPlayback(ClientVideoScreen sc } 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 @@ -460,15 +460,15 @@ public static void reloadQualityPlayback(ClientVideoScreen screen) { video.whenComplete((resolved, error) -> { if (error != null) { LOGGER.warn("Failed to reload quality-limited source {}", VideoProviders.redactedSource(current.rawPath()), error); - MinecraftClient.getInstance().execute(() -> screen.failPlaybackRequest(playbackToken)); + Minecraft.getInstance().execute(() -> screen.failPlaybackRequest(playbackToken)); return; } if (!LocalPlaybackInfo.playable(resolved)) { - MinecraftClient.getInstance().execute(() -> screen.failPlaybackRequest(playbackToken)); + Minecraft.getInstance().execute(() -> screen.failPlaybackRequest(playbackToken)); return; } VideoInfo selected = LocalPlaybackInfo.select(current, resolved); - MinecraftClient.getInstance().execute(() -> { + Minecraft.getInstance().execute(() -> { if (!screen.canAcceptPlayback(playbackToken)) return; if (progress > 0) screen.setToSeek(progress); screen.play(selected, idle); @@ -518,7 +518,7 @@ private static void handleLoadArea(ByteBuf buf, long receivedAt) { screen.trackPlaybackFuture(playbackToken, video); video.whenComplete((resolved, error) -> { if (error != null || resolved == null) { - MinecraftClient.getInstance().execute(() -> { + Minecraft.getInstance().execute(() -> { if (!screen.isPlaybackRequestCurrent(playbackToken)) return; screen.setServerPlaybackResolution(generation, null); reportClientPlaybackResolution(screen, info, generation, null); @@ -526,7 +526,7 @@ private static void handleLoadArea(ByteBuf buf, long receivedAt) { }); return; } - MinecraftClient.getInstance().execute(() -> { + Minecraft.getInstance().execute(() -> { if (!screen.isPlaybackRequestCurrent(playbackToken)) return; if (resolved.seekable() && seek >= 0) { screen.setToSeek(seek + Math.max(0L, System.currentTimeMillis() - receivedAt)); @@ -567,13 +567,13 @@ private static void handleUpdatePlaylist(ByteBuf buf) { } private static void handleExecute(ByteBuf buf) { - MinecraftClient client = MinecraftClient.getInstance(); + Minecraft client = Minecraft.getInstance(); CommandDispatcher dispatcher = ClientCommandManager.getActiveDispatcher(); if (dispatcher == null || client.player == null) return; try { - dispatcher.execute("vlc " + ByteBufUtils.readString(buf, 1024), (FabricClientCommandSource) client.player.networkHandler.getCommandSource()); + dispatcher.execute("vlc " + ByteBufUtils.readString(buf, 1024), (FabricClientCommandSource) client.player.connection.getSuggestionsProvider()); } catch (CommandSyntaxException e) { - client.player.sendMessage(VpTexts.tr("message.videoplayer.command_failed", "Command failed: %s", e).formatted(Formatting.RED), false); + client.player.displayClientMessage(VpTexts.tr("message.videoplayer.command_failed", "Command failed: %s", e).withStyle(ChatFormatting.RED), false); } } @@ -707,9 +707,7 @@ public static boolean failed(RequestResult result) { } public static void config(String version) { - ByteBuf buf = VideoPackets.create(VideoPacketType.CONFIG); - writeString(buf, VideoProtocol.token(version)); - send(VideoPackets.toByteArray(buf)); + send(VideoPackets.clientConfig(version)); } private static void handshakeAck(long nonce) { diff --git a/src/client/java/com/github/squi2rel/vp/ClientYtDlpInstaller.java b/src/client/java/com/github/squi2rel/vp/ClientYtDlpInstaller.java index 60c7194..86e0fc9 100644 --- a/src/client/java/com/github/squi2rel/vp/ClientYtDlpInstaller.java +++ b/src/client/java/com/github/squi2rel/vp/ClientYtDlpInstaller.java @@ -1,6 +1,6 @@ package com.github.squi2rel.vp; -import net.minecraft.client.MinecraftClient; +import net.minecraft.client.Minecraft; import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicReference; @@ -49,7 +49,7 @@ private static CompletableFuture ensureStarted() { return; } publish(result); - MinecraftClient client = MinecraftClient.getInstance(); + Minecraft client = Minecraft.getInstance(); if (client != null) client.execute(VideoPlayerClient::applyNativePlatformConfig); }); return created; diff --git a/src/client/java/com/github/squi2rel/vp/ScreenRenderer.java b/src/client/java/com/github/squi2rel/vp/ScreenRenderer.java index 14dbeeb..9116848 100644 --- a/src/client/java/com/github/squi2rel/vp/ScreenRenderer.java +++ b/src/client/java/com/github/squi2rel/vp/ScreenRenderer.java @@ -4,33 +4,42 @@ import com.github.squi2rel.vp.danmaku.ClientDanmakuRenderer; import com.github.squi2rel.vp.video.ExternalGlTexture; import com.github.squi2rel.vp.mixin.client.DrawContextAccessor; +import com.github.squi2rel.vp.render.ExternalTextureRegistry; import com.github.squi2rel.vp.vivecraft.Vivecraft; import com.mojang.blaze3d.buffers.GpuBuffer; import com.mojang.blaze3d.buffers.GpuBufferSlice; +import com.mojang.blaze3d.opengl.GlTexture; import com.mojang.blaze3d.pipeline.BlendFunction; import com.mojang.blaze3d.pipeline.RenderPipeline; import com.mojang.blaze3d.platform.DepthTestFunction; import com.mojang.blaze3d.systems.RenderPass; import com.mojang.blaze3d.systems.RenderSystem; import com.mojang.blaze3d.textures.GpuTextureView; +import com.mojang.blaze3d.vertex.BufferBuilder; +import com.mojang.blaze3d.vertex.ByteBufferBuilder; +import com.mojang.blaze3d.vertex.DefaultVertexFormat; +import com.mojang.blaze3d.vertex.MeshData; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.Tesselator; +import com.mojang.blaze3d.vertex.VertexConsumer; import com.mojang.blaze3d.vertex.VertexFormat; import net.fabricmc.fabric.api.client.rendering.v1.world.WorldRenderContext; -import net.minecraft.client.MinecraftClient; -import net.minecraft.client.gui.DrawContext; -import net.minecraft.client.gui.ScreenRect; -import net.minecraft.client.gui.render.state.SimpleGuiElementRenderState; -import net.minecraft.client.gui.render.state.TexturedQuadGuiElementRenderState; -import net.minecraft.client.gl.RenderPipelines; -import net.minecraft.client.render.*; -import net.minecraft.client.texture.AbstractTexture; -import net.minecraft.client.util.BufferAllocator; -import net.minecraft.client.util.math.MatrixStack; -import net.minecraft.client.texture.TextureSetup; -import net.minecraft.client.texture.GlTexture; -import net.minecraft.util.Identifier; -import net.minecraft.util.math.Vec3d; -import net.minecraft.util.profiler.Profiler; -import net.minecraft.util.profiler.Profilers; +import net.minecraft.client.Camera; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.navigation.ScreenRectangle; +import net.minecraft.client.gui.render.TextureSetup; +import net.minecraft.client.gui.render.state.BlitRenderState; +import net.minecraft.client.gui.render.state.GuiElementRenderState; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.client.renderer.rendertype.RenderSetup; +import net.minecraft.client.renderer.rendertype.RenderType; +import net.minecraft.client.renderer.texture.AbstractTexture; +import net.minecraft.resources.Identifier; +import net.minecraft.util.profiling.Profiler; +import net.minecraft.util.profiling.ProfilerFiller; +import net.minecraft.world.phys.Vec3; import org.joml.Matrix3x2f; import org.joml.Matrix4f; import org.joml.Quaternionf; @@ -50,48 +59,48 @@ @SuppressWarnings({"resource", "DataFlowIssue"}) public class ScreenRenderer { private static final String SAMPLER = "Sampler0"; - private static final Identifier PLACEHOLDER_TEXTURE = Identifier.of("videoplayer", "placeholder.png"); - private static final Map textureIds = new HashMap<>(); - private static final Map layers = new HashMap<>(); - private static final RenderPipeline VIDEO_WORLD_QUADS = RenderPipelines.register(RenderPipeline.builder(RenderPipelines.POSITION_TEX_COLOR_SNIPPET) - .withLocation(Identifier.of("videoplayer", "video_world_quads")) - .withVertexFormat(VertexFormats.POSITION_TEXTURE_COLOR, VertexFormat.DrawMode.QUADS) + private static final Identifier PLACEHOLDER_TEXTURE = Identifier.fromNamespaceAndPath("videoplayer", "placeholder.png"); + private static final ExternalTextureRegistry EXTERNAL_TEXTURES = new ExternalTextureRegistry(); + private static final Map layers = new HashMap<>(); + private static final RenderPipeline VIDEO_WORLD_QUADS = RenderPipelines.register(RenderPipeline.builder(RenderPipelines.GUI_TEXTURED_SNIPPET) + .withLocation(Identifier.fromNamespaceAndPath("videoplayer", "video_world_quads")) + .withVertexFormat(DefaultVertexFormat.POSITION_TEX_COLOR, VertexFormat.Mode.QUADS) .withDepthTestFunction(DepthTestFunction.LEQUAL_DEPTH_TEST) .withCull(false) .withBlend(BlendFunction.TRANSLUCENT) .build()); - private static final RenderPipeline VIDEO_WORLD_TRIANGLE_STRIP = RenderPipelines.register(RenderPipeline.builder(RenderPipelines.POSITION_TEX_COLOR_SNIPPET) - .withLocation(Identifier.of("videoplayer", "video_world_triangle_strip")) - .withVertexFormat(VertexFormats.POSITION_TEXTURE_COLOR, VertexFormat.DrawMode.TRIANGLE_STRIP) + private static final RenderPipeline VIDEO_WORLD_TRIANGLE_STRIP = RenderPipelines.register(RenderPipeline.builder(RenderPipelines.GUI_TEXTURED_SNIPPET) + .withLocation(Identifier.fromNamespaceAndPath("videoplayer", "video_world_triangle_strip")) + .withVertexFormat(DefaultVertexFormat.POSITION_TEX_COLOR, VertexFormat.Mode.TRIANGLE_STRIP) .withDepthTestFunction(DepthTestFunction.LEQUAL_DEPTH_TEST) .withCull(false) .withBlend(BlendFunction.TRANSLUCENT) .build()); - private static final RenderPipeline VIDEO_WORLD_PREMULTIPLIED_QUADS = RenderPipelines.register(RenderPipeline.builder(RenderPipelines.POSITION_TEX_COLOR_SNIPPET) - .withLocation(Identifier.of("videoplayer", "video_world_premultiplied_quads")) - .withVertexFormat(VertexFormats.POSITION_TEXTURE_COLOR, VertexFormat.DrawMode.QUADS) + private static final RenderPipeline VIDEO_WORLD_PREMULTIPLIED_QUADS = RenderPipelines.register(RenderPipeline.builder(RenderPipelines.GUI_TEXTURED_SNIPPET) + .withLocation(Identifier.fromNamespaceAndPath("videoplayer", "video_world_premultiplied_quads")) + .withVertexFormat(DefaultVertexFormat.POSITION_TEX_COLOR, VertexFormat.Mode.QUADS) .withDepthTestFunction(DepthTestFunction.LEQUAL_DEPTH_TEST) .withCull(false) .withBlend(BlendFunction.TRANSLUCENT_PREMULTIPLIED_ALPHA) .withDepthWrite(false) .build()); - private static final RenderPipeline VIDEO_GUI_QUADS = RenderPipelines.register(RenderPipeline.builder(RenderPipelines.POSITION_TEX_COLOR_SNIPPET) - .withLocation(Identifier.of("videoplayer", "video_gui_quads")) - .withVertexFormat(VertexFormats.POSITION_TEXTURE_COLOR, VertexFormat.DrawMode.QUADS) + private static final RenderPipeline VIDEO_GUI_QUADS = RenderPipelines.register(RenderPipeline.builder(RenderPipelines.GUI_TEXTURED_SNIPPET) + .withLocation(Identifier.fromNamespaceAndPath("videoplayer", "video_gui_quads")) + .withVertexFormat(DefaultVertexFormat.POSITION_TEX_COLOR, VertexFormat.Mode.QUADS) .withDepthTestFunction(DepthTestFunction.NO_DEPTH_TEST) .withCull(false) .withBlend(BlendFunction.TRANSLUCENT) .build()); - private static final RenderPipeline VIDEO_GUI_TRIANGLES = RenderPipelines.register(RenderPipeline.builder(RenderPipelines.POSITION_TEX_COLOR_SNIPPET) - .withLocation(Identifier.of("videoplayer", "video_gui_triangles")) - .withVertexFormat(VertexFormats.POSITION_TEXTURE_COLOR, VertexFormat.DrawMode.TRIANGLES) + private static final RenderPipeline VIDEO_GUI_TRIANGLES = RenderPipelines.register(RenderPipeline.builder(RenderPipelines.GUI_TEXTURED_SNIPPET) + .withLocation(Identifier.fromNamespaceAndPath("videoplayer", "video_gui_triangles")) + .withVertexFormat(DefaultVertexFormat.POSITION_TEX_COLOR, VertexFormat.Mode.TRIANGLES) .withDepthTestFunction(DepthTestFunction.NO_DEPTH_TEST) .withCull(false) .withBlend(BlendFunction.TRANSLUCENT) .build()); - private static final RenderPipeline GUI_COLOR_QUADS_PIPELINE = RenderPipelines.register(RenderPipeline.builder(RenderPipelines.POSITION_COLOR_SNIPPET) - .withLocation(Identifier.of("videoplayer", "gui_color_quads")) - .withVertexFormat(VertexFormats.POSITION_COLOR, VertexFormat.DrawMode.QUADS) + private static final RenderPipeline GUI_COLOR_QUADS_PIPELINE = RenderPipelines.register(RenderPipeline.builder(RenderPipelines.DEBUG_FILLED_SNIPPET) + .withLocation(Identifier.fromNamespaceAndPath("videoplayer", "gui_color_quads")) + .withVertexFormat(DefaultVertexFormat.POSITION_COLOR, VertexFormat.Mode.QUADS) .withDepthTestFunction(DepthTestFunction.NO_DEPTH_TEST) .withCull(false) .withBlend(BlendFunction.TRANSLUCENT) @@ -110,13 +119,13 @@ public class ScreenRenderer { public static void render(WorldRenderContext ctx) { if (CameraRenderer.rendering) return; skybox = false; - Profiler profiler = Profilers.get(); + ProfilerFiller profiler = Profiler.get(); profiler.push("video"); profiler.push("render"); - MatrixStack matrices = ctx.matrices(); - matrices.push(); - Camera cameraObject = MinecraftClient.getInstance().gameRenderer.getCamera(); - Vec3d camera = cameraObject.getCameraPos(); + PoseStack matrices = ctx.matrices(); + matrices.pushPose(); + Camera cameraObject = Minecraft.getInstance().gameRenderer.getMainCamera(); + Vec3 camera = cameraObject.position(); preciseCameraX = camera.x; preciseCameraY = camera.y; preciseCameraZ = camera.z; @@ -126,12 +135,12 @@ public static void render(WorldRenderContext ctx) { if (Vivecraft.loaded && Vivecraft.isVRActive()) { rotation.setFromNormalized(Vivecraft.getRotation()).invert(); } else { - cameraObject.getRotation().invert(rotation); + cameraObject.rotation().invert(rotation); } ClientDanmakuRenderer.beginFrame(screens); - BufferAllocator allocator = new BufferAllocator(4096); + ByteBufferBuilder allocator = new ByteBufferBuilder(4096); try { - VertexConsumerProvider.Immediate consumers = VertexConsumerProvider.immediate(allocator); + MultiBufferSource.BufferSource consumers = MultiBufferSource.immediate(allocator); for (ClientVideoScreen screen : screens) { try { screen.draw(matrices, consumers); @@ -139,34 +148,34 @@ public static void render(WorldRenderContext ctx) { VideoPlayerMain.LOGGER.error("Exception while rendering", e); } } - consumers.draw(); + consumers.endBatch(); } catch (RuntimeException e) { VideoPlayerMain.LOGGER.warn("Failed to draw video screen buffers", e); } finally { allocator.close(); } - matrices.pop(); + matrices.popPose(); profiler.pop(); profiler.pop(); } - public static RenderLayer getLayer(int textureId) { + public static RenderType getLayer(int textureId) { return texturedLayer(textureId, LayerKind.WORLD); } - public static RenderLayer getLayer(Identifier texture) { + public static RenderType getLayer(Identifier texture) { return texturedLayer(texture, LayerKind.WORLD); } - public static RenderLayer getTranslucentLayer(int textureId) { + public static RenderType getTranslucentLayer(int textureId) { return texturedLayer(textureId, LayerKind.WORLD_TRANSLUCENT); } - public static RenderLayer getTranslucentLayer(Identifier texture) { + public static RenderType getTranslucentLayer(Identifier texture) { return texturedLayer(texture, LayerKind.WORLD_TRANSLUCENT); } - public static RenderLayer getPremultipliedTranslucentLayer(Identifier texture) { + public static RenderType getPremultipliedTranslucentLayer(Identifier texture) { return texturedLayer(texture, LayerKind.WORLD_PREMULTIPLIED_TRANSLUCENT); } @@ -174,30 +183,30 @@ public static void removeTextureLayers(Identifier texture) { layers.keySet().removeIf(key -> key.textureId instanceof Identifier identifier && identifier.equals(texture)); } - public static RenderLayer getBackingLayer(int textureId) { + public static RenderType getBackingLayer(int textureId) { return texturedLayer(textureId, LayerKind.WORLD_BACKING); } - public static RenderLayer getGuiLayer(int textureId) { + public static RenderType getGuiLayer(int textureId) { return texturedLayer(textureId, LayerKind.GUI_QUADS); } - public static RenderLayer getGuiTriangleLayer(int textureId) { + public static RenderType getGuiTriangleLayer(int textureId) { return texturedLayer(textureId, LayerKind.GUI_TRIANGLES); } - public static RenderLayer getGuiColorQuadLayer() { + public static RenderType getGuiColorQuadLayer() { return layers.computeIfAbsent(new LayerKey(0, LayerKind.GUI_COLOR_QUADS), key -> - RenderLayer.of("videoplayer_gui_color_quads", RenderSetup.builder(GUI_COLOR_QUADS_PIPELINE) - .expectedBufferSize(256) - .translucent() - .build())); + RenderType.create("videoplayer_gui_color_quads", RenderSetup.builder(GUI_COLOR_QUADS_PIPELINE) + .bufferSize(256) + .sortOnUpload() + .createRenderSetup())); } - public static void drawGuiLayer(RenderLayer layer, Consumer drawer) { - BufferBuilder buffer = Tessellator.getInstance().begin(layer.getDrawMode(), layer.getVertexFormat()); + public static void drawGuiLayer(RenderType layer, Consumer drawer) { + BufferBuilder buffer = Tesselator.getInstance().begin(layer.mode(), layer.format()); drawer.accept(buffer); - BuiltBuffer built = buffer.endNullable(); + MeshData built = buffer.build(); if (built != null) { layer.draw(built); } @@ -209,27 +218,27 @@ public static void drawWorldTexturedMesh(int textureId, Matrix4f modelMatrix, Gp return; } - AbstractTexture texture = MinecraftClient.getInstance().getTextureManager().getTexture(textureIdentifier(textureId)); - if (texture.getGlTextureView() == null || texture.getSampler() == null) return; + AbstractTexture texture = Minecraft.getInstance().getTextureManager().getTexture(textureIdentifier(textureId)); + if (texture.getTextureView() == null || texture.getSampler() == null) return; Matrix4f modelView = new Matrix4f(RenderSystem.getModelViewMatrix()).mul(modelMatrix); Matrix4f textureTransform = new Matrix4f(); Vector3f modelOffset = new Vector3f(); - GpuBufferSlice textureTransforms = RenderSystem.getDynamicUniforms().write( + GpuBufferSlice textureTransforms = RenderSystem.getDynamicUniforms().writeTransform( modelView, color(textureColor), modelOffset, textureTransform ); - var framebuffer = MinecraftClient.getInstance().getFramebuffer(); + var framebuffer = Minecraft.getInstance().getMainRenderTarget(); GpuTextureView colorTarget = RenderSystem.outputColorTextureOverride != null ? RenderSystem.outputColorTextureOverride - : framebuffer.getColorAttachmentView(); - GpuTextureView depthTarget = framebuffer.useDepthAttachment + : framebuffer.getColorTextureView(); + GpuTextureView depthTarget = framebuffer.useDepth ? (RenderSystem.outputDepthTextureOverride != null ? RenderSystem.outputDepthTextureOverride - : framebuffer.getDepthAttachmentView()) + : framebuffer.getDepthTextureView()) : null; try (RenderPass pass = RenderSystem.getDevice().createCommandEncoder().createRenderPass( @@ -241,40 +250,40 @@ public static void drawWorldTexturedMesh(int textureId, Matrix4f modelMatrix, Gp )) { pass.setPipeline(VIDEO_WORLD_TRIANGLE_STRIP); var scissor = RenderSystem.getScissorStateForRenderTypeDraws(); - if (scissor.isEnabled()) { - pass.enableScissor(scissor.getX(), scissor.getY(), scissor.getWidth(), scissor.getHeight()); + if (scissor.enabled()) { + pass.enableScissor(scissor.x(), scissor.y(), scissor.width(), scissor.height()); } RenderSystem.bindDefaultUniforms(pass); - pass.bindTexture(SAMPLER, texture.getGlTextureView(), texture.getSampler()); + pass.bindTexture(SAMPLER, texture.getTextureView(), texture.getSampler()); pass.setVertexBuffer(0, vertexBuffer); pass.setUniform("DynamicTransforms", textureTransforms); pass.draw(0, vertexCount); } } - public static void drawGuiTexturedTriangles(DrawContext context, int textureId, List vertices) { + public static void drawGuiTexturedTriangles(GuiGraphics context, int textureId, List vertices) { if (vertices == null || vertices.size() < 3) return; - AbstractTexture texture = MinecraftClient.getInstance().getTextureManager().getTexture(textureIdentifier(textureId)); - Matrix3x2f pose = new Matrix3x2f(context.getMatrices()); + 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().addSimpleElement(new GuiTexturedTrianglesRenderState( - TextureSetup.of(texture.getGlTextureView(), texture.getSampler()), + ((DrawContextAccessor) context).videoplayer$getState().submitGuiElement(new GuiTexturedTrianglesRenderState( + TextureSetup.singleTexture(texture.getTextureView(), texture.getSampler()), pose, copiedVertices, bounds(copiedVertices, pose) )); } - public static void drawGuiPremultipliedTexturedQuad(DrawContext context, Identifier identifier, + public static void drawGuiPremultipliedTexturedQuad(GuiGraphics context, Identifier identifier, int x1, int y1, int x2, int y2, float u1, float u2, float v1, float v2, int color) { - AbstractTexture texture = MinecraftClient.getInstance().getTextureManager().getTexture(identifier); - ((DrawContextAccessor) context).videoplayer$getState().addSimpleElement(new TexturedQuadGuiElementRenderState( + AbstractTexture texture = Minecraft.getInstance().getTextureManager().getTexture(identifier); + ((DrawContextAccessor) context).videoplayer$getState().submitGuiElement(new BlitRenderState( RenderPipelines.GUI_TEXTURED_PREMULTIPLIED_ALPHA, - TextureSetup.of(texture.getGlTextureView(), texture.getSampler()), - new Matrix3x2f(context.getMatrices()), + TextureSetup.singleTexture(texture.getTextureView(), texture.getSampler()), + new Matrix3x2f(context.pose()), x1, y1, x2, @@ -284,11 +293,11 @@ public static void drawGuiPremultipliedTexturedQuad(DrawContext context, Identif v1, v2, color, - context.scissorStack.peekLast() + context.scissorStack.peek() )); } - private static ScreenRect bounds(List vertices, Matrix3x2f pose) { + private static ScreenRectangle bounds(List vertices, Matrix3x2f pose) { Vector2f transformed = new Vector2f(); float minX = Float.POSITIVE_INFINITY; float minY = Float.POSITIVE_INFINITY; @@ -305,51 +314,83 @@ private static ScreenRect bounds(List vertices, Matrix3x2f pose) { int y = (int) Math.floor(minY); int width = Math.max(1, (int) Math.ceil(maxX) - x); int height = Math.max(1, (int) Math.ceil(maxY) - y); - return new ScreenRect(x, y, width, height); + return new ScreenRectangle(x, y, width, height); } - private static RenderLayer texturedLayer(int textureId, LayerKind kind) { - return layers.computeIfAbsent(new LayerKey(textureId, kind), key -> - RenderLayer.of("videoplayer_" + kind.name().toLowerCase() + "_" + textureId, setup(textureId, kind))); + private static RenderType texturedLayer(int textureId, LayerKind kind) { + return texturedLayer(textureIdentifier(textureId), kind); } - private static RenderLayer texturedLayer(Identifier texture, LayerKind kind) { + private static RenderType texturedLayer(Identifier texture, LayerKind kind) { return layers.computeIfAbsent(new LayerKey(texture, kind), key -> - RenderLayer.of("videoplayer_" + kind.name().toLowerCase() + "_" + texture.toString().replace(':', '_').replace('/', '_'), setup(texture, kind))); - } - - private static RenderSetup setup(int textureId, LayerKind kind) { - return setup(textureIdentifier(textureId), kind); + RenderType.create("videoplayer_" + kind.name().toLowerCase() + "_" + texture.toString().replace(':', '_').replace('/', '_'), setup(texture, kind))); } private static RenderSetup setup(Identifier texture, LayerKind kind) { - RenderSetup.Builder builder = RenderSetup.builder(kind.pipeline) - .texture(SAMPLER, texture) - .expectedBufferSize(kind.expectedBufferSize); + RenderSetup.RenderSetupBuilder builder = RenderSetup.builder(kind.pipeline) + .withTexture(SAMPLER, texture) + .bufferSize(kind.expectedBufferSize); if (kind.useOverlay) builder.useOverlay(); if (kind.useLightmap) builder.useLightmap(); - if (kind.translucent) builder.translucent(); - return builder.build(); + if (kind.translucent) builder.sortOnUpload(); + return builder.createRenderSetup(); } public static Identifier textureIdentifier(int textureId) { if (textureId < 0) return PLACEHOLDER_TEXTURE; - return textureIds.computeIfAbsent(textureId, id -> { - Identifier identifier = Identifier.of("videoplayer", "external_texture/" + id); - MinecraftClient.getInstance().getTextureManager().registerTexture(identifier, new ExternalGlTexture(id, 1, 1)); - return identifier; + 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; + EXTERNAL_TEXTURES.release(textureId).ifPresent(ScreenRenderer::releaseTexture); + } + + public static void clearExternalTextures() { + List registrations = EXTERNAL_TEXTURES.clear(); + runOnClientThread(() -> { + for (ExternalTextureRegistry.Registration registration : registrations) { + Minecraft.getInstance().getTextureManager().release(textureIdentifier(registration)); + } + layers.clear(); }); } + private static void releaseTexture(ExternalTextureRegistry.Registration registration) { + Identifier identifier = textureIdentifier(registration); + runOnClientThread(() -> { + Minecraft.getInstance().getTextureManager().release(identifier); + layers.keySet().removeIf(key -> key.textureId.equals(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() { - if (MinecraftClient.getInstance().getTextureManager().getTexture(PLACEHOLDER_TEXTURE).getGlTexture() instanceof GlTexture texture) { - return texture.getGlId(); + if (Minecraft.getInstance().getTextureManager().getTexture(PLACEHOLDER_TEXTURE).getTexture() instanceof GlTexture texture) { + return texture.glId(); } return -1; } - public static void rotateMatrix(MatrixStack matrices) { - matrices.multiply(rotation); + public static void rotateMatrix(PoseStack matrices) { + matrices.mulPose(rotation); } public static void drawWorldTexturedVertex(Matrix4f matrix, VertexConsumer consumer, Vector3f vertex, @@ -360,9 +401,9 @@ public static void drawWorldTexturedVertex(Matrix4f matrix, VertexConsumer consu 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) { - consumer.vertex(matrix, x, y, z) - .color(color) - .texture(u, v); + consumer.addVertex(matrix, x, y, z) + .setColor(color) + .setUv(u, v); } private static Vector4f color(int color) { @@ -403,9 +444,9 @@ public record GuiVertex(float x, float y, float u, float v, int color) { } private record GuiTexturedTrianglesRenderState(TextureSetup textureSetup, Matrix3x2f pose, List vertices, - ScreenRect bounds) implements SimpleGuiElementRenderState { + ScreenRectangle bounds) implements GuiElementRenderState { @Override - public void setupVertices(VertexConsumer consumer) { + public void buildVertices(VertexConsumer consumer) { // The vanilla GUI renderer indexes simple elements with a quad index buffer. // Submit each triangle as a degenerate quad so every triangle survives batching. for (int i = 0; i + 2 < vertices.size(); i += 3) { @@ -425,13 +466,13 @@ public RenderPipeline pipeline() { } @Override - public ScreenRect scissorArea() { + public ScreenRectangle scissorArea() { return null; } } private static void setupVertex(VertexConsumer consumer, Matrix3x2f pose, GuiVertex vertex) { - consumer.vertex(pose, vertex.x, vertex.y).texture(vertex.u, vertex.v).color(vertex.color); + consumer.addVertexWith2DPose(pose, vertex.x, vertex.y).setUv(vertex.u, vertex.v).setColor(vertex.color); } private enum LayerKind { diff --git a/src/client/java/com/github/squi2rel/vp/VideoPlayerClient.java b/src/client/java/com/github/squi2rel/vp/VideoPlayerClient.java index de51d63..8e7050f 100644 --- a/src/client/java/com/github/squi2rel/vp/VideoPlayerClient.java +++ b/src/client/java/com/github/squi2rel/vp/VideoPlayerClient.java @@ -43,22 +43,27 @@ 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.world.WorldRenderEvents; +import net.fabricmc.fabric.api.resource.ResourceManagerHelper; +import net.fabricmc.fabric.api.resource.SimpleSynchronousResourceReloadListener; import net.fabricmc.loader.api.FabricLoader; -import net.minecraft.client.MinecraftClient; -import net.minecraft.client.gui.hud.ClientBossBar; -import net.minecraft.client.network.ClientPlayNetworkHandler; -import net.minecraft.component.DataComponentTypes; -import net.minecraft.component.type.CustomModelDataComponent; -import net.minecraft.entity.boss.BossBar; -import net.minecraft.item.ItemStack; -import net.minecraft.network.packet.s2c.play.BossBarS2CPacket; -import net.minecraft.registry.Registries; -import net.minecraft.text.Text; -import net.minecraft.util.Formatting; -import net.minecraft.util.Hand; -import net.minecraft.util.math.Vec3d; -import net.minecraft.util.profiler.Profiler; -import net.minecraft.util.profiler.Profilers; +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.ResourceManager; +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; @@ -75,12 +80,13 @@ 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 MinecraftClient client = MinecraftClient.getInstance(); + public static final Minecraft client = Minecraft.getInstance(); private static final VideoConnectionDiagnostics connectionDiagnostics = new VideoConnectionDiagnostics( HANDSHAKE_TIMEOUT_MS, System::currentTimeMillis, @@ -95,7 +101,7 @@ public class VideoPlayerClient implements ClientModInitializer { private static final TouchHandler touchHandler = new TouchHandler(); private static ClientVideoScreen currentLooking, currentScreen; private static boolean isInArea = false; - private static final BossBar bossBar = new ClientBossBar(UUID.randomUUID(), Text.of(""), 0, BossBar.Color.WHITE, BossBar.Style.PROGRESS, false, false, 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; @@ -153,13 +159,14 @@ public class VideoPlayerClient implements ClientModInitializer { @Override public void onInitializeClient() { if (error != null) { - ClientPlayConnectionEvents.JOIN.register((h, s, c) -> c.player.sendMessage(VpTexts.tr( + ClientPlayConnectionEvents.JOIN.register((h, s, c) -> c.player.displayClientMessage(VpTexts.tr( "message.videoplayer.backend_load_failed", "VideoPlayer error: video backend failed to load\n%s\nSee logs for more information", error - ).formatted(Formatting.RED), false)); + ).withStyle(ChatFormatting.RED), false)); } loadConfig(); + registerExternalTextureReload(); activeAudioChannelMode = AudioChannelMode.normalize(config.audioChannelMode); BiliBiliProvider.setCookieSupplier(BiliCookie::header); YouTubeProvider.configureMissingYtdlHandler(() -> { @@ -258,7 +265,7 @@ public void onInitializeClient() { 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).formatted(Formatting.GREEN)); + s.getSource().sendFeedback(VpTexts.tr("message.videoplayer.volume_set", "Volume set to %s%%", v).withStyle(ChatFormatting.GREEN)); applyConfiguredVolume(); return 1; }))) @@ -268,7 +275,7 @@ public void onInitializeClient() { .then(ClientCommandManager.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)).formatted(Formatting.GREEN)); + s.getSource().sendFeedback(VpTexts.tr("message.videoplayer.current_backend", "Current playback backend: %s", VideoBackends.normalize(config.videoBackend)).withStyle(ChatFormatting.GREEN)); return 1; })) .then(ClientCommandManager.literal("audio") @@ -290,7 +297,7 @@ public void onInitializeClient() { 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() - ).formatted(Formatting.GREEN)); + ).withStyle(ChatFormatting.GREEN)); return 1; })) .then(ClientCommandManager.literal("createArea") @@ -403,7 +410,7 @@ public void onInitializeClient() { 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 - ).formatted(Formatting.GOLD)); + ).withStyle(ChatFormatting.GOLD)); return 1; })) .then(ClientCommandManager.literal("sync") @@ -416,7 +423,7 @@ public void onInitializeClient() { .then(ClientCommandManager.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).formatted(Formatting.GREEN)); + s.getSource().sendFeedback(VpTexts.tr("message.videoplayer.brightness_set", "Brightness set to %s%%", config.brightness).withStyle(ChatFormatting.GREEN)); saveConfig(); return 1; }))) @@ -485,7 +492,7 @@ public void onInitializeClient() { if (screen == null) return 0; String key = s.getArgument("key", String.class); MetaValue value = screen.metadata.get(key); - s.getSource().sendFeedback(Text.of(key + "=" + (value == null ? "null" : value.toDisplayString()))); + s.getSource().sendFeedback(Component.literal(key + "=" + (value == null ? "null" : value.toDisplayString()))); return 1; }))) .then(ClientCommandManager.literal("remove") @@ -500,7 +507,7 @@ public void onInitializeClient() { .executes(s -> { ClientVideoScreen screen = getScreen(s); if (screen == null) return 0; - s.getSource().sendFeedback(Text.of(screen.metadata.entries().toString())); + s.getSource().sendFeedback(Component.literal(screen.metadata.entries().toString())); return 1; }))) ))) @@ -531,14 +538,14 @@ public void onInitializeClient() { .redirect(videoplayerRoot)); }); ClientTickEvents.END_CLIENT_TICK.register(client -> { - if (client.player == null || client.world == null || client.currentScreen != null || currentLooking == null) return; - boolean pressed = client.options.useKey.isPressed(); + if (client.player == null || client.level == null || client.screen != null || currentLooking == null) return; + boolean pressed = client.options.keyUse.isDown(); if (pressed && !keyPressed) { keyPressed = true; - if (remoteControl || client.player.getStackInHand(Hand.MAIN_HAND).isEmpty() && client.player.getStackInHand(Hand.OFF_HAND).isEmpty()) { + 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.currentScreen == null) { + if (!ClientPacketHandler.failed(result) && client.screen == null) { VideoCreationEditor.instance().openConfigScreen(selected); } }); @@ -572,7 +579,7 @@ private static int showCommandHelp(CommandContext con context.getSource().sendFeedback(VpTexts.tr( "command.videoplayer.help.header", "VideoPlayer client commands. Use /videoplayer help for details." - ).formatted(Formatting.GOLD)); + ).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( @@ -585,7 +592,7 @@ private static int showCommandHelp(CommandContext con context.getSource().sendFeedback(VpTexts.tr( "command.videoplayer.help.alias", "/vlc remains a compatible alias for /videoplayer." - ).formatted(Formatting.GRAY)); + ).withStyle(ChatFormatting.GRAY)); return 1; } Optional found = VideoPlayerCommandHelp.find(subcommand); @@ -594,14 +601,14 @@ private static int showCommandHelp(CommandContext con "command.videoplayer.help.unknown", "Unknown subcommand '%s'. Use /videoplayer help to list available commands.", subcommand - ).formatted(Formatting.RED)); + ).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(Text.literal(usage).formatted(Formatting.AQUA)); + 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() @@ -618,18 +625,18 @@ private static LiteralArgumentBuilder biliAuthCommand .then(ClientCommandManager.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").formatted(Formatting.GREEN)); + s.getSource().sendFeedback(VpTexts.tr("message.videoplayer.bilibili_cookie_saved", "Bilibili auth saved locally").withStyle(ChatFormatting.GREEN)); return 1; }))) .then(ClientCommandManager.literal("clear") .executes(s -> { BiliCookie.clear(); - s.getSource().sendFeedback(VpTexts.tr("message.videoplayer.bilibili_cookie_cleared", "Bilibili auth cleared").formatted(Formatting.GREEN)); + s.getSource().sendFeedback(VpTexts.tr("message.videoplayer.bilibili_cookie_cleared", "Bilibili auth cleared").withStyle(ChatFormatting.GREEN)); return 1; })) .then(ClientCommandManager.literal("status") .executes(s -> { - s.getSource().sendFeedback(VpTexts.text(BiliCookie.status()).formatted(Formatting.GREEN)); + s.getSource().sendFeedback(VpTexts.text(BiliCookie.status()).withStyle(ChatFormatting.GREEN)); return 1; })); } @@ -654,7 +661,7 @@ private static LiteralArgumentBuilder youtubeAuthComm s.getSource().sendFeedback(VpTexts.tr( "message.videoplayer.youtube_auth_cleared", "YouTube authentication settings cleared" - ).formatted(Formatting.GREEN)); + ).withStyle(ChatFormatting.GREEN)); return 1; })) .then(ClientCommandManager.literal("status") @@ -668,7 +675,7 @@ private static LiteralArgumentBuilder youtubeAuthComm "YouTube authentication: cookie file=%s, browser profile=%s", file ? configured : notConfigured, browser ? configured : notConfigured - ).formatted(Formatting.GREEN)); + ).withStyle(ChatFormatting.GREEN)); return 1; })); } @@ -692,10 +699,10 @@ private static int setVideoBackend(CommandContext s, 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." - ).formatted(Formatting.YELLOW)); + ).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).formatted(Formatting.GREEN)); + 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; } @@ -709,7 +716,7 @@ private static int showAudioChannelMode(CommandContext ? "Audio channel mode saved as %s. Restart Minecraft to apply." : "Audio channel mode saved as %s and is already active.", audioChannelModeLabel(mode).getString() - ).formatted(restartRequired ? Formatting.YELLOW : Formatting.GREEN)); + ).withStyle(restartRequired ? ChatFormatting.YELLOW : ChatFormatting.GREEN)); return 1; } - private static Text audioChannelModeLabel(AudioChannelMode mode) { + private static Component audioChannelModeLabel(AudioChannelMode mode) { return VpTexts.tr( "label.videoplayer.audio_channel_mode." + mode.configValue(), mode == AudioChannelMode.AUTO ? "Auto" : "Stereo" @@ -739,7 +746,7 @@ private ClientVideoArea getArea(CommandContext s) { 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).formatted(Formatting.RED)); + s.getSource().sendFeedback(VpTexts.tr("error.videoplayer.area_named_not_found", "No video area named %s", name).withStyle(ChatFormatting.RED)); return null; } return area; @@ -752,7 +759,7 @@ private ClientVideoScreen getScreen(CommandContext s) 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").formatted(Formatting.RED)); + s.getSource().sendFeedback(VpTexts.tr("error.videoplayer.screen_not_found", "Screen not found").withStyle(ChatFormatting.RED)); return null; } return screen; @@ -760,14 +767,14 @@ private ClientVideoScreen getScreen(CommandContext s) private boolean checkInvalid(CommandContext s, boolean checkScreen) { if (!connected && !config.alwaysConnected) { - s.getSource().sendFeedback(VpTexts.tr("error.videoplayer.not_connected", "Not connected to server").formatted(Formatting.RED)); + 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").formatted(Formatting.RED)); + 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").formatted(Formatting.RED)); + s.getSource().sendFeedback(VpTexts.tr("error.videoplayer.not_inside_area", "You are not inside a video area").withStyle(ChatFormatting.RED)); } return true; } @@ -776,25 +783,25 @@ private boolean checkInvalid(CommandContext s, boolea private boolean checkInvalidLooking(CommandContext s) { if (!connected && !config.alwaysConnected) { - s.getSource().sendFeedback(VpTexts.tr("error.videoplayer.not_connected", "Not connected to server").formatted(Formatting.RED)); + 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").formatted(Formatting.RED)); + 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() { - ClientPlayNetworkHandler handler = client.getNetworkHandler(); + ClientPacketListener handler = client.getConnection(); if (handler == null) { bossBarAdded = false; return; } if (currentLooking != null) { if (!bossBarAdded) { - handler.onBossBar(BossBarS2CPacket.add(bossBar)); + handler.handleBossUpdate(ClientboundBossEventPacket.createAddPacket(bossBar)); bossBarAdded = true; } ClientVideoScreen screen = currentLooking.getScreen(); @@ -807,26 +814,26 @@ private static void updateBossBar() { if (totalProgress > 0) { boolean showHour = progress >= 3600000 || totalProgress >= 3600000; time = formatDuration(progress, showHour) + "/" + formatDuration(totalProgress, showHour); - bossBar.setPercent((float) progress / totalProgress); + bossBar.setProgress((float) progress / totalProgress); } else { time = formatDuration(progress, progress >= 3600000) + "/LIVE"; - bossBar.setPercent(0); + bossBar.setProgress(0); } - bossBar.setName(Text.of(name + " " + time)); + bossBar.setName(Component.nullToEmpty(name + " " + time)); } else { bossBar.setName(VpTexts.tr("label.videoplayer.none", "None")); - bossBar.setPercent(1); + bossBar.setProgress(1); } - handler.onBossBar(BossBarS2CPacket.updateName(bossBar)); - handler.onBossBar(BossBarS2CPacket.updateProgress(bossBar)); + handler.handleBossUpdate(ClientboundBossEventPacket.createUpdateNamePacket(bossBar)); + handler.handleBossUpdate(ClientboundBossEventPacket.createUpdateProgressPacket(bossBar)); } else if (bossBarAdded) { - handler.onBossBar(BossBarS2CPacket.remove(bossBar.getUuid())); + handler.handleBossUpdate(ClientboundBossEventPacket.createRemovePacket(bossBar.getId())); bossBarAdded = false; } } private static void checkInteract() { - MinecraftClient client = VideoPlayerClient.client; + Minecraft client = VideoPlayerClient.client; if (client == null) return; isInArea = false; @@ -837,22 +844,22 @@ private static void checkInteract() { return; } - float delta = VideoPlayerClient.client.getRenderTickCounter().getTickProgress(true); - Vec3d eyePos = client.player.getCameraPosVec(delta); - Vec3d lookVec = client.player.getRotationVec(delta); + 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.getMainHandStack(), client.player.getOffHandStack())) { - if (!Registries.ITEM.getId(item.getItem()).toString().equals(remoteControlName)) continue; - CustomModelDataComponent data = item.getComponents().get(DataComponentTypes.CUSTOM_MODEL_DATA); + 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; } - Vec3d end = eyePos.add(lookVec.multiply(remoteControl ? remoteControlRange : noControlRange)); + Vec3 end = eyePos.add(lookVec.scale(remoteControl ? remoteControlRange : noControlRange)); Vector3d lineEnd = new Vector3d(end.x, end.y, end.z); ArrayList list = new ArrayList<>(); @@ -893,7 +900,7 @@ public static boolean checkVersion(String v) { public static void update() { ClientPacketHandler.tickPendingRequests(); if (updated) return; - Profiler profiler = Profilers.get(); + ProfilerFiller profiler = Profiler.get(); profiler.push("video"); profiler.push("updateFrame"); for (ClientVideoScreen screen : screens) { @@ -901,19 +908,19 @@ public static void update() { screen.swapTexture(); screen.update(); } - profiler.swap("checkInteract"); + profiler.popPush("checkInteract"); checkInteract(); - profiler.swap("updateBossBar"); + profiler.popPush("updateBossBar"); updateBossBar(); profiler.pop(); profiler.pop(); } private static void cleanupClientState() { - if (client.currentScreen instanceof ServerStateScreen) { + if (client.screen instanceof ServerStateScreen) { client.setScreen(null); if (client.player != null) { - client.player.sendMessage(VpTexts.tr("error.videoplayer.server_state_reset", "VideoPlayer server state was reset").formatted(Formatting.RED), false); + client.player.displayClientMessage(VpTexts.tr("error.videoplayer.server_state_reset", "VideoPlayer server state was reset").withStyle(ChatFormatting.RED), false); } } connected = false; @@ -927,6 +934,7 @@ private static void cleanupClientState() { screen.cleanup(); } screens.clear(); + ScreenRenderer.clearExternalTextures(); ClientPacketHandler.resetPendingRequests(); ScreenVolumeCache.clear(); ClientDanmakuRenderer.clearCache(); @@ -937,7 +945,7 @@ private static void cleanupClientState() { currentScreen = null; remoteControl = false; touchHandler.handle(null); - if (client.getNetworkHandler() != null) { + if (client.getConnection() != null) { updateBossBar(); } else { bossBarAdded = false; @@ -945,9 +953,23 @@ private static void cleanupClientState() { VideoCreationEditor.instance().clear(); } + private static void registerExternalTextureReload() { + ResourceManagerHelper.get(PackType.CLIENT_RESOURCES).registerReloadListener(new SimpleSynchronousResourceReloadListener() { + @Override + public Identifier getFabricId() { + return Identifier.fromNamespaceAndPath("videoplayer", "external_textures"); + } + + @Override + public void onResourceManagerReload(ResourceManager resourceManager) { + ScreenRenderer.clearExternalTextures(); + } + }); + } + private static int openDiagnostics(CommandContext context) { if (!connected && !config.alwaysConnected) { - context.getSource().sendFeedback(VpTexts.tr("error.videoplayer.not_connected", "Not connected to server").formatted(Formatting.RED)); + context.getSource().sendFeedback(VpTexts.tr("error.videoplayer.not_connected", "Not connected to server").withStyle(ChatFormatting.RED)); return 0; } ClientVideoScreen selected = currentLooking != null ? currentLooking : currentScreen; @@ -958,7 +980,7 @@ private static int openDiagnostics(CommandContext con return 1; } ClientPacketHandler.openMenu(target, result -> { - if (!ClientPacketHandler.failed(result) && client.currentScreen == null) { + if (!ClientPacketHandler.failed(result) && client.screen == null) { client.setScreen(VideoManagementScreen.diagnostics(VideoCreationEditor.instance(), target)); } }); @@ -1002,15 +1024,15 @@ public static void rejectProtocol(String remoteVersion) { if (connectionDiagnostics.snapshot().trigger() == VideoConnectionDiagnostics.Trigger.MANUAL_RETRY || protocolMismatchShown || client.player == null) return; protocolMismatchShown = true; - client.player.sendMessage(VpTexts.tr( + client.player.displayClientMessage(VpTexts.tr( "message.videoplayer.version_mismatch", "VideoPlayer client version %s is not compatible with server %s", VideoPlayerMain.version, remoteVersion == null || remoteVersion.isBlank() ? "unknown" : remoteVersion - ).formatted(Formatting.RED), false); + ).withStyle(ChatFormatting.RED), false); } - private static void tickHandshake(MinecraftClient client) { - if (client.getNetworkHandler() == null || client.player == null) { + private static void tickHandshake(Minecraft client) { + if (client.getConnection() == null || client.player == null) { joinHandshakePending = false; connectionDiagnostics.disconnected(); return; @@ -1034,7 +1056,7 @@ public static VideoConnectionDiagnostics.Snapshot connectionSnapshot() { } public static void reconnectServer() { - if (client.getNetworkHandler() == null || client.player == null) { + 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; @@ -1054,9 +1076,9 @@ public static void reconnectServer() { } private static String currentServerAddress() { - var server = client.getCurrentServerEntry(); - if (server == null || server.address == null || server.address.isBlank()) return "local"; - return server.address; + 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) { @@ -1095,33 +1117,33 @@ private static void logConnectionEvent(VideoConnectionDiagnostics.Event event) { private static void notifyManualReconnect(VideoConnectionDiagnostics.Event event) { VideoConnectionDiagnostics.Snapshot snapshot = event.snapshot(); if (snapshot.trigger() != VideoConnectionDiagnostics.Trigger.MANUAL_RETRY || client.player == null) return; - Text message; - Formatting formatting; + Component message; + ChatFormatting formatting; switch (event.type()) { case ATTEMPT_STARTED -> { message = VpTexts.tr("message.videoplayer.reconnect_started", "Reconnecting to the VideoPlayer server..."); - formatting = Formatting.YELLOW; + 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 = Formatting.RED; + 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 = Formatting.RED; + formatting = ChatFormatting.RED; } case CONNECTED -> { message = VpTexts.tr("message.videoplayer.reconnect_success", "VideoPlayer server reconnected. Server version: %s", reconnectVersion(snapshot.remoteVersion())); - formatting = Formatting.GREEN; + 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 = Formatting.RED; + formatting = ChatFormatting.RED; } case CHANNEL_AVAILABLE, DISCONNECTED -> { return; @@ -1130,7 +1152,7 @@ private static void notifyManualReconnect(VideoConnectionDiagnostics.Event event return; } } - client.player.sendMessage(message.copy().formatted(formatting), false); + client.player.displayClientMessage(message.copy().withStyle(formatting), false); } private static String reconnectVersion(String version) { @@ -1151,7 +1173,7 @@ private static String logField(String value) { public static void postUpdate() { if (updated) return; updated = true; - Profiler profiler = Profilers.get(); + ProfilerFiller profiler = Profiler.get(); profiler.push("video"); profiler.push("updateFrame"); for (ClientVideoScreen screen : screens) { @@ -1356,9 +1378,9 @@ private static void loadConfig() { private static void registerStartupGuide() { ClientTickEvents.END_CLIENT_TICK.register(client -> { if (startupGuideOpened || config == null || Boolean.TRUE.equals(config.startupGuideShown)) return; - if (client.world != null || client.currentScreen == null || client.currentScreen instanceof StartupGuideScreen) return; + if (client.level != null || client.screen == null || client.screen instanceof StartupGuideScreen) return; startupGuideOpened = true; - client.setScreen(new StartupGuideScreen(client.currentScreen)); + client.setScreen(new StartupGuideScreen(client.screen)); }); } @@ -1366,8 +1388,8 @@ private static void registerStartupGuideScreenOpener() { ClientTickEvents.END_CLIENT_TICK.register(client -> { if (!pendingStartupGuideScreen) return; pendingStartupGuideScreen = false; - if (client.currentScreen instanceof StartupGuideScreen) return; - client.setScreen(new StartupGuideScreen(client.currentScreen)); + if (client.screen instanceof StartupGuideScreen) return; + client.setScreen(new StartupGuideScreen(client.screen)); }); } @@ -1375,8 +1397,8 @@ private static void registerBiliLoginScreenOpener() { ClientTickEvents.END_CLIENT_TICK.register(client -> { if (!pendingBiliLoginScreen) return; pendingBiliLoginScreen = false; - if (client.currentScreen instanceof BiliLoginScreen) return; - client.setScreen(new BiliLoginScreen(client.currentScreen)); + if (client.screen instanceof BiliLoginScreen) return; + client.setScreen(new BiliLoginScreen(client.screen)); }); } @@ -1389,8 +1411,8 @@ private static void registerYouTubeAuthScreenOpener() { ClientTickEvents.END_CLIENT_TICK.register(client -> { if (!pendingYouTubeAuthScreen) return; pendingYouTubeAuthScreen = false; - if (client.currentScreen instanceof YouTubeAuthScreen) return; - client.setScreen(new YouTubeAuthScreen(client.currentScreen)); + if (client.screen instanceof YouTubeAuthScreen) return; + client.setScreen(new YouTubeAuthScreen(client.screen)); }); } diff --git a/src/client/java/com/github/squi2rel/vp/creation/BiliLoginScreen.java b/src/client/java/com/github/squi2rel/vp/creation/BiliLoginScreen.java index 3521898..ef21f53 100644 --- a/src/client/java/com/github/squi2rel/vp/creation/BiliLoginScreen.java +++ b/src/client/java/com/github/squi2rel/vp/creation/BiliLoginScreen.java @@ -9,15 +9,7 @@ import com.google.zxing.common.BitMatrix; import com.google.zxing.qrcode.QRCodeWriter; import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel; -import net.minecraft.client.MinecraftClient; -import net.minecraft.client.gui.DrawContext; -import net.minecraft.client.gui.screen.Screen; -import net.minecraft.client.texture.NativeImage; -import net.minecraft.client.texture.NativeImageBackedTexture; -import net.minecraft.client.texture.TextureManager; -import net.minecraft.text.Text; -import net.minecraft.util.Identifier; - +import com.mojang.blaze3d.platform.NativeImage; import java.nio.charset.StandardCharsets; import java.util.EnumMap; import java.util.Map; @@ -25,6 +17,13 @@ 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.GuiGraphics; +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(); @@ -47,7 +46,7 @@ public class BiliLoginScreen extends Screen { private boolean closing; private Identifier qrIdentifier; - private NativeImageBackedTexture qrTexture; + private DynamicTexture qrTexture; public BiliLoginScreen(Screen parent) { super(VpTexts.tr("screen.videoplayer.bili_login", "Bilibili Login")); @@ -67,9 +66,9 @@ protected void init() { 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 -> close(), THEME); - addDrawableChild(refreshButton); - addDrawableChild(closeButton); + VpTexts.tr("button.videoplayer.close", "Close"), ignored -> onClose(), THEME); + addRenderableWidget(refreshButton); + addRenderableWidget(closeButton); syncButtons(); if (qrCode == null && activeRequest == null) { @@ -91,9 +90,9 @@ public void tick() { } @Override - public void close() { - if (client != null) { - client.setScreen(parent); + public void onClose() { + if (minecraft != null) { + minecraft.setScreen(parent); } } @@ -108,12 +107,12 @@ public void removed() { } @Override - public boolean shouldPause() { + public boolean isPauseScreen() { return false; } @Override - public void render(DrawContext context, int mouseX, int mouseY, float delta) { + public void render(GuiGraphics context, int mouseX, int mouseY, float delta) { context.fill(0, 0, width, height, 0xB0000000); int panelW = panelWidth(); @@ -124,20 +123,20 @@ public void render(DrawContext context, int mouseX, int mouseY, float delta) { int bottom = top + panelH; context.fill(left, top, right, bottom, THEME.panelBackgroundColor()); - context.drawStrokedRectangle(left, top, panelW, panelH, THEME.panelBorderColor()); - context.drawCenteredTextWithShadow(textRenderer, title, width / 2, top + 14, THEME.primaryTextColor()); + context.renderOutline(left, top, panelW, panelH, THEME.panelBorderColor()); + context.drawCenteredString(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.drawTexturedQuad(qrIdentifier, qrX, qrY, qrX + qrSize, qrY + qrSize, 0, 1, 0, 1); + context.blit(qrIdentifier, qrX, qrY, qrX + qrSize, qrY + qrSize, 0, 1, 0, 1); } - Text statusText = VpTexts.text(status); + Component statusText = VpTexts.text(status); int statusY = Math.min(bottom - 62, qrY + qrSize + 16); - context.drawCenteredTextWithShadow(textRenderer, statusText, width / 2, statusY, statusColor()); + context.drawCenteredString(font, statusText, width / 2, statusY, statusColor()); super.render(context, mouseX, mouseY, delta); } @@ -202,12 +201,12 @@ private void startPoll() { private void createQrTexture(String url) throws WriterException { NativeImage image = createQrImage(url); - Identifier identifier = Identifier.of("videoplayer", "bili_login/qr/" + TEXTURE_COUNTER.incrementAndGet()); - NativeImageBackedTexture texture = null; + Identifier identifier = Identifier.fromNamespaceAndPath("videoplayer", "bili_login/qr/" + TEXTURE_COUNTER.incrementAndGet()); + DynamicTexture texture = null; boolean registered = false; try { - texture = new NativeImageBackedTexture(() -> "VideoPlayer Bilibili QR", image); - MinecraftClient.getInstance().getTextureManager().registerTexture(identifier, texture); + texture = new DynamicTexture(() -> "VideoPlayer Bilibili QR", image); + Minecraft.getInstance().getTextureManager().register(identifier, texture); registered = true; qrIdentifier = identifier; qrTexture = texture; @@ -228,7 +227,7 @@ private NativeImage createQrImage(String url) throws WriterException { 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.setColorArgb(x, y, matrix.get(x, y) ? 0xFF000000 : 0xFFFFFFFF); + image.setPixel(x, y, matrix.get(x, y) ? 0xFF000000 : 0xFFFFFFFF); } } return image; @@ -236,13 +235,13 @@ private NativeImage createQrImage(String url) throws WriterException { private void destroyQrTexture() { Identifier identifier = qrIdentifier; - NativeImageBackedTexture texture = qrTexture; + DynamicTexture texture = qrTexture; qrIdentifier = null; qrTexture = null; if (identifier != null) { - TextureManager textureManager = MinecraftClient.getInstance().getTextureManager(); + TextureManager textureManager = Minecraft.getInstance().getTextureManager(); try { - textureManager.destroyTexture(identifier); + textureManager.release(identifier); return; } catch (RuntimeException ignored) { // Fall through to close the texture directly. @@ -281,7 +280,7 @@ private boolean isCurrent(int token) { } private void runOnClient(Runnable task) { - MinecraftClient client = MinecraftClient.getInstance(); + Minecraft client = Minecraft.getInstance(); if (client == null) return; client.execute(task); } diff --git a/src/client/java/com/github/squi2rel/vp/creation/IdlePlayListScreen.java b/src/client/java/com/github/squi2rel/vp/creation/IdlePlayListScreen.java index ee3ff02..ec8ec12 100644 --- a/src/client/java/com/github/squi2rel/vp/creation/IdlePlayListScreen.java +++ b/src/client/java/com/github/squi2rel/vp/creation/IdlePlayListScreen.java @@ -9,14 +9,13 @@ import com.github.squi2rel.vp.video.ClientVideoScreen; import com.github.squi2rel.vp.video.IdlePlayEntry; import com.github.squi2rel.vp.video.VideoScreen; -import net.minecraft.client.gui.Click; -import net.minecraft.client.gui.DrawContext; -import net.minecraft.client.gui.screen.Screen; -import net.minecraft.client.gui.widget.TextFieldWidget; -import net.minecraft.text.Text; -import net.minecraft.util.Formatting; - import java.util.function.Consumer; +import net.minecraft.ChatFormatting; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.EditBox; +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; @@ -27,8 +26,8 @@ public class IdlePlayListScreen extends Screen implements ServerStateScreen { private final Screen parent; private final ClientVideoScreen screen; - private TextFieldWidget urlField; - private TextFieldWidget priorityField; + private EditBox urlField; + private EditBox priorityField; private String urlDraft = ""; private String priorityDraft = "0"; private int listScroll; @@ -63,17 +62,17 @@ protected void init() { int addW = 56; int priorityW = 42; int urlW = Math.max(80, contentW - addW - priorityW - GAP * 2); - urlField = new VpTextFieldWidget(textRenderer, x, row, urlW, CONTROL_HEIGHT, Text.empty(), THEME); + urlField = new VpTextFieldWidget(font, x, row, urlW, CONTROL_HEIGHT, Component.empty(), THEME); urlField.setMaxLength(VideoScreen.MAX_IDLE_PLAY_URL_BYTES); - urlField.setTextPredicate(VideoScreen::validIdlePlayUrlInput); - urlField.setText(urlDraft); - addDrawableChild(urlField); - priorityField = new VpTextFieldWidget(textRenderer, x + urlW + GAP, row, priorityW, CONTROL_HEIGHT, Text.empty(), THEME); + 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.setTextPredicate(value -> value.isEmpty() || value.chars().allMatch(Character::isDigit)); - priorityField.setText(priorityDraft); - priorityField.setChangedListener(value -> priorityDraft = value); - addDrawableChild(priorityField); + 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; @@ -84,28 +83,28 @@ protected void init() { refreshControls(); int closeW = 72; - button(VpTexts.tr("button.videoplayer.close", "Close"), x + Math.max(0, contentW - closeW), Math.max(108, height - 40), closeW, this::close); + button(VpTexts.tr("button.videoplayer.close", "Close"), x + Math.max(0, contentW - closeW), Math.max(108, height - 40), closeW, this::onClose); } @Override - public boolean shouldPause() { + public boolean isPauseScreen() { return false; } @Override - public void close() { - if (client != null) { - client.setScreen(parent); + public void onClose() { + if (minecraft != null) { + minecraft.setScreen(parent); } } @Override - public void renderBackground(DrawContext context, int mouseX, int mouseY, float delta) { + public void renderBackground(GuiGraphics context, int mouseX, int mouseY, float delta) { context.fill(0, 0, width, height, VpUiRenderer.withAlpha(THEME.canvasBackgroundColor(), 0xCC)); } @Override - public void render(DrawContext context, int mouseX, int mouseY, float delta) { + public void render(GuiGraphics context, int mouseX, int mouseY, float delta) { computeLayout(); renderBackground(context, mouseX, mouseY, delta); int panelX = 18; @@ -127,7 +126,7 @@ public void render(DrawContext context, int mouseX, int mouseY, float delta) { } @Override - public boolean mouseClicked(Click click, boolean doubleClick) { + public boolean mouseClicked(MouseButtonEvent click, boolean doubleClick) { if (click.button() == 0 && clickListControls(click.x(), click.y())) { return true; } @@ -145,7 +144,7 @@ public boolean mouseScrolled(double mouseX, double mouseY, double horizontalAmou return false; } if (urlField != null) { - urlDraft = urlField.getText(); + urlDraft = urlField.getValue(); } listScroll = next; return true; @@ -161,7 +160,7 @@ private void computeLayout() { listScroll = Math.clamp(listScroll, 0, maxListScroll()); } - private void drawIdleList(DrawContext context, int mouseX, int mouseY) { + private void drawIdleList(GuiGraphics 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()) { @@ -197,7 +196,7 @@ private void drawIdleList(DrawContext context, int mouseX, int mouseY) { context.disableScissor(); } - private void drawListButton(DrawContext context, String label, int x, int y, int width, boolean active, boolean hovered) { + private void drawListButton(GuiGraphics 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); @@ -205,7 +204,7 @@ private void drawListButton(DrawContext context, String label, int x, int y, int 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, Text.literal(label), x + width / 2, y + 5, text); + drawCenteredText(context, Component.literal(label), x + width / 2, y + 5, text); } private boolean clickListControls(double mouseX, double mouseY) { @@ -239,7 +238,7 @@ private boolean clickListControls(double mouseX, double mouseY) { return true; } - private void drawScrollbar(DrawContext context) { + private void drawScrollbar(GuiGraphics context) { int contentHeight = listContentHeight(); int viewportHeight = listBottom - listTop; int maxScroll = Math.max(0, contentHeight - viewportHeight); @@ -270,7 +269,7 @@ private int listContentHeight() { private void addIdlePlayUrl(VpButtonWidget button) { if (screen == null || urlField == null || priorityField == null) return; - String url = VideoUrlNormalizer.normalizeSubmittedUrl(urlField.getText()); + 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; @@ -291,7 +290,7 @@ private void addIdlePlayUrl(VpButtonWidget button) { } int priority; try { - priority = Integer.parseInt(priorityField.getText().isBlank() ? "0" : priorityField.getText()); + 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; @@ -301,7 +300,7 @@ private void addIdlePlayUrl(VpButtonWidget button) { return; } urlDraft = ""; - urlField.setText(""); + urlField.setValue(""); sendIdlePlayMutation(callback -> ClientPacketHandler.addIdlePlay(screen, url, priority, callback), button); } @@ -327,16 +326,16 @@ private void toggleIdlePlayMode(VpButtonWidget button) { private void sendIdlePlayMutation(Consumer> sender, VpButtonWidget button) { if (screen == null || requestPending || !canEditIdlePlay()) return; - String currentUrl = urlField == null ? urlDraft : urlField.getText(); + 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 (client != null && client.currentScreen == this) { + if (minecraft != null && minecraft.screen == this) { listScroll = Math.clamp(listScroll, 0, maxListScroll()); - clearAndInit(); + rebuildWidgets(); } }); } @@ -354,40 +353,40 @@ private void refreshControls() { if (clearButton != null) clearButton.active = editable && screen != null && !screen.idlePlayEntries.isEmpty(); } - private Text idlePlayModeText() { - Text mode = screen == null || !screen.idlePlayRandom + 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(Text message) { - if (client != null && client.player != null) { - client.player.sendMessage(message.copy().formatted(Formatting.RED), false); + private void sendLocalError(Component message) { + if (minecraft != null && minecraft.player != null) { + minecraft.player.displayClientMessage(message.copy().withStyle(ChatFormatting.RED), false); } } - private VpButtonWidget button(Text label, int x, int y, int width, Runnable 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); - addDrawableChild(button); + addRenderableWidget(button); return button; } - private VpButtonWidget button(Text label, int x, int y, int width, Consumer 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); - addDrawableChild(button); + 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, Text.literal(label), b -> action.run(), THEME); - addDrawableChild(button); + 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, Text.literal(label), action, THEME); - addDrawableChild(button); + VpButtonWidget button = new VpButtonWidget(x, y, Math.max(34, width), CONTROL_HEIGHT, Component.literal(label), action, THEME); + addRenderableWidget(button); return button; } @@ -399,28 +398,28 @@ private boolean canEditIdlePlay() { && ClientPermissionCache.allowedOrUnknown(VideoPermissionAction.SET_IDLE_PLAY, screen); } - private void drawLabel(DrawContext context, String label, int x, int y, int color) { - drawLabel(context, Text.literal(label), x, y, color); + private void drawLabel(GuiGraphics context, String label, int x, int y, int color) { + drawLabel(context, Component.literal(label), x, y, color); } - private void drawLabel(DrawContext context, Text label, int x, int y, int color) { + private void drawLabel(GuiGraphics context, Component label, int x, int y, int color) { if (THEME.textShadow()) { - context.drawTextWithShadow(textRenderer, label, x, y, color); + context.drawString(font, label, x, y, color); return; } - context.drawText(textRenderer, label, x, y, color, false); + context.drawString(font, label, x, y, color, false); } - private void drawCenteredText(DrawContext context, Text text, int centerX, int y, int color) { - int x = centerX - textRenderer.getWidth(text) / 2; + private void drawCenteredText(GuiGraphics 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 (textRenderer.getWidth(value) <= maxWidth) return value; + if (font.width(value) <= maxWidth) return value; String suffix = "..."; - return textRenderer.trimToWidth(value, Math.max(0, maxWidth - textRenderer.getWidth(suffix))) + 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) { diff --git a/src/client/java/com/github/squi2rel/vp/creation/MpvFilterGraphScreen.java b/src/client/java/com/github/squi2rel/vp/creation/MpvFilterGraphScreen.java index 00395f3..d2bd3b8 100644 --- a/src/client/java/com/github/squi2rel/vp/creation/MpvFilterGraphScreen.java +++ b/src/client/java/com/github/squi2rel/vp/creation/MpvFilterGraphScreen.java @@ -16,13 +16,13 @@ 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.Click; -import net.minecraft.client.gui.DrawContext; -import net.minecraft.client.gui.screen.Screen; -import net.minecraft.client.input.CharInput; -import net.minecraft.client.input.KeyInput; -import net.minecraft.client.resource.language.I18n; -import net.minecraft.text.Text; +import net.minecraft.client.gui.GuiGraphics; +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.network.chat.Component; import org.lwjgl.glfw.GLFW; public class MpvFilterGraphScreen extends Screen implements GraphEditorHost { @@ -30,7 +30,7 @@ public class MpvFilterGraphScreen extends Screen implements GraphEditorHost { 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) -> - I18n.hasTranslation(key) ? I18n.translate(key, args) : GraphEditorI18n.formatFallback(fallback, key, args); + I18n.exists(key) ? I18n.get(key, args) : GraphEditorI18n.formatFallback(fallback, key, args); private final Screen parent; private final GraphJsonCodec codec = new GraphJsonCodec(); @@ -68,9 +68,9 @@ protected void init() { 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()); - addDrawableChild(applyButton); - addDrawableChild(autoApplyButton); - editor.init(textRenderer, editorBounds()); + addRenderableWidget(applyButton); + addRenderableWidget(autoApplyButton); + editor.init(font, editorBounds()); syncStatusFromCompile(); } @@ -84,30 +84,30 @@ public void tick() { } @Override - public void render(DrawContext context, int mouseX, int mouseY, float delta) { + public void render(GuiGraphics context, int mouseX, int mouseY, float delta) { editor.setBounds(editorBounds()); - editor.render(context, textRenderer, mouseX, mouseY, delta); + editor.render(context, font, mouseX, mouseY, delta); context.fill(0, 0, width, TOP_BAR_HEIGHT, THEME.panelBackgroundColor()); - context.drawTextWithShadow(textRenderer, title, 8, 10, THEME.primaryTextColor()); + context.drawString(font, title, 8, 10, THEME.primaryTextColor()); int statusRight = Math.max(80, width - 184); - String visible = textRenderer.trimToWidth(status == null ? "" : status, statusRight - 90); - context.drawTextWithShadow(textRenderer, Text.literal(visible), 90, 10, statusColor()); + String visible = font.plainSubstrByWidth(status == null ? "" : status, statusRight - 90); + context.drawString(font, Component.literal(visible), 90, 10, statusColor()); super.render(context, mouseX, mouseY, delta); } @Override - public boolean mouseClicked(Click click, boolean doubleClick) { + 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(Click click, double deltaX, double deltaY) { + 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(Click click) { + public boolean mouseReleased(MouseButtonEvent click) { return editor.mouseReleased(click.x(), click.y(), click.button()) || super.mouseReleased(click); } @@ -117,7 +117,7 @@ public boolean mouseScrolled(double mouseX, double mouseY, double horizontalAmou } @Override - public boolean keyPressed(KeyInput input) { + 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(); @@ -127,22 +127,22 @@ public boolean keyPressed(KeyInput input) { } @Override - public boolean charTyped(CharInput input) { - if (input.isValidChar()) { - String value = input.asString(); + public boolean charTyped(CharacterEvent input) { + if (input.isAllowedChatCharacter()) { + String value = input.codepointAsString(); if (value.length() == 1 && editor.charTyped(value.charAt(0), input.modifiers())) return true; } return super.charTyped(input); } @Override - public void close() { + public void onClose() { editor.close(); - if (client != null) client.setScreen(parent); + if (minecraft != null) minecraft.setScreen(parent); } @Override - public boolean shouldPause() { + public boolean isPauseScreen() { return false; } @@ -157,12 +157,12 @@ public void onDocumentChanged(GraphDocument document) { @Override public void copyToClipboard(String value) { - if (client != null) client.keyboard.setClipboard(value); + if (minecraft != null) minecraft.keyboardHandler.setClipboard(value); } @Override public String readClipboard() { - return client == null ? "" : client.keyboard.getClipboard(); + return minecraft == null ? "" : minecraft.keyboardHandler.getClipboard(); } @Override @@ -223,7 +223,7 @@ private int statusColor() { return statusError ? THEME.errorColor() : THEME.secondaryTextColor(); } - private Text autoApplyText() { + private Component autoApplyText() { return VpTexts.tr("label.videoplayer.mpv_auto_apply", "Auto: %s", MpvFilterGraphManager.autoApply() ? VpTexts.tr("label.videoplayer.on", "On") diff --git a/src/client/java/com/github/squi2rel/vp/creation/SelectionPreviewRenderer.java b/src/client/java/com/github/squi2rel/vp/creation/SelectionPreviewRenderer.java index c71008d..f65659d 100644 --- a/src/client/java/com/github/squi2rel/vp/creation/SelectionPreviewRenderer.java +++ b/src/client/java/com/github/squi2rel/vp/creation/SelectionPreviewRenderer.java @@ -6,14 +6,18 @@ 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.fabricmc.fabric.api.client.rendering.v1.world.WorldRenderContext; -import net.minecraft.client.MinecraftClient; -import net.minecraft.client.gui.DrawContext; -import net.minecraft.client.render.*; -import net.minecraft.client.util.math.MatrixStack; -import net.minecraft.text.Text; -import net.minecraft.util.math.Box; -import net.minecraft.util.math.Vec3d; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.renderer.MultiBufferSource; +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; @@ -40,17 +44,17 @@ private SelectionPreviewRenderer() { public static void renderWorld(WorldRenderContext ctx) { VideoCreationEditor editor = VideoCreationEditor.instance(); if (!editor.active()) return; - VertexConsumerProvider consumers = ctx.consumers(); + MultiBufferSource consumers = ctx.consumers(); - MatrixStack matrices = ctx.matrices(); + PoseStack matrices = ctx.matrices(); if (matrices == null) return; - Vec3d camera = MinecraftClient.getInstance().gameRenderer.getCamera().getCameraPos(); - matrices.push(); + Vec3 camera = Minecraft.getInstance().gameRenderer.getMainCamera().position(); + matrices.pushPose(); drawScreenPreviewTexture(editor, matrices, consumers, camera); - VertexConsumer consumer = consumers.getBuffer(RenderLayers.lines()); + VertexConsumer consumer = consumers.getBuffer(RenderTypes.lines()); drawExistingAreas(matrices, consumer, camera); drawExistingScreens(editor, matrices, consumer, camera); drawAreaPreview(editor, matrices, consumer, camera); @@ -58,31 +62,31 @@ public static void renderWorld(WorldRenderContext ctx) { drawSelectionPoints(editor, matrices, consumer, camera); drawGizmo(editor, matrices, consumer, camera); - matrices.pop(); + matrices.popPose(); } - public static void renderHud(DrawContext context, RenderTickCounter tickCounter) { + public static void renderHud(GuiGraphics context, DeltaTracker tickCounter) { VideoCreationEditor editor = VideoCreationEditor.instance(); if (!editor.selecting()) return; - MinecraftClient client = MinecraftClient.getInstance(); - int x = context.getScaledWindowWidth() / 2 + 12; - int y = context.getScaledWindowHeight() / 2 + 12; + Minecraft client = Minecraft.getInstance(); + int x = context.guiWidth() / 2 + 12; + int y = context.guiHeight() / 2 + 12; int color = editor.statusError() ? 0xFFFF5555 : 0xFFFFFFFF; - context.drawTextWithShadow(client.textRenderer, editor.modeText(), x, y, 0xFFFFD050); - context.drawTextWithShadow(client.textRenderer, VpTexts.tr("label.videoplayer.point_progress", "Points %s", editor.pointProgress()), x, y + 11, 0xFFE0E0E0); - context.drawTextWithShadow(client.textRenderer, editor.status(), x, y + 22, color); + context.drawString(client.font, editor.modeText(), x, y, 0xFFFFD050); + context.drawString(client.font, VpTexts.tr("label.videoplayer.point_progress", "Points %s", editor.pointProgress()), x, y + 11, 0xFFE0E0E0); + context.drawString(client.font, editor.status(), x, y + 22, color); VideoCreationEditor.SelectionPoint selected = editor.selectedPoint(); if (editor.screenGizmoVisible() && selected != null) { - context.drawTextWithShadow(client.textRenderer, VpTexts.tr("label.videoplayer.selected_point", "Selected %s: %s", editor.selectedPointIndex() + 1, selected.format()), x, y + 33, 0xFFB0B0B0); + context.drawString(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.drawTextWithShadow(client.textRenderer, Text.literal(target.format()), x, y + 33, 0xFFB0B0B0); + context.drawString(client.font, Component.literal(target.format()), x, y + 33, 0xFFB0B0B0); } } - private static void drawExistingAreas(MatrixStack matrices, VertexConsumer consumer, Vec3d camera) { + private static void drawExistingAreas(PoseStack matrices, VertexConsumer consumer, Vec3 camera) { for (ClientVideoArea area : VideoPlayerClient.areas.values()) { drawBox( matrices, @@ -95,7 +99,7 @@ private static void drawExistingAreas(MatrixStack matrices, VertexConsumer consu } } - private static void drawExistingScreens(VideoCreationEditor editor, MatrixStack matrices, VertexConsumer consumer, Vec3d 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) { @@ -108,8 +112,8 @@ private static void drawExistingScreens(VideoCreationEditor editor, MatrixStack } } - private static void drawAreaPreview(VideoCreationEditor editor, MatrixStack matrices, VertexConsumer consumer, Vec3d camera) { - Box box = editor.areaPreview(); + private static void drawAreaPreview(VideoCreationEditor editor, PoseStack matrices, VertexConsumer consumer, Vec3 camera) { + AABB box = editor.areaPreview(); if (box == null) return; drawBox( matrices, @@ -121,7 +125,7 @@ private static void drawAreaPreview(VideoCreationEditor editor, MatrixStack matr ); } - private static void drawScreenPreviewTexture(VideoCreationEditor editor, MatrixStack matrices, VertexConsumerProvider consumers, Vec3d camera) { + private static void drawScreenPreviewTexture(VideoCreationEditor editor, PoseStack matrices, MultiBufferSource consumers, Vec3 camera) { if (editor.selectingSpherePreset()) return; List vertices = editor.previewVertices(); if (vertices != null) { @@ -129,7 +133,7 @@ private static void drawScreenPreviewTexture(VideoCreationEditor editor, MatrixS } } - private static void drawScreenPreview(VideoCreationEditor editor, MatrixStack matrices, VertexConsumer consumer, Vec3d 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) { @@ -146,7 +150,7 @@ private static void drawScreenPreview(VideoCreationEditor editor, MatrixStack ma if (editor.draft().target != VideoCreationEditor.Target.SCREEN) return; if (editor.points().isEmpty()) return; - Matrix4f matrix = matrices.peek().getPositionMatrix(); + 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); } @@ -156,7 +160,7 @@ private static void drawScreenPreview(VideoCreationEditor editor, MatrixStack ma } } - private static void drawSelectionPoints(VideoCreationEditor editor, MatrixStack matrices, VertexConsumer consumer, Vec3d 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); @@ -167,12 +171,12 @@ private static void drawSelectionPoints(VideoCreationEditor editor, MatrixStack } } - private static void drawGizmo(VideoCreationEditor editor, MatrixStack matrices, VertexConsumer consumer, Vec3d 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.peek().getPositionMatrix(); + 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); @@ -189,7 +193,7 @@ private static int axisColor(VideoCreationEditor editor, VideoCreationEditor.Giz } private static void drawAxis(Matrix4f matrix, VertexConsumer consumer, Vector3f origin, - VideoCreationEditor.GizmoAxis axis, float startDistance, float length, int color, Vec3d camera) { + 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)); @@ -214,7 +218,7 @@ private static Vector3f arrowSide(VideoCreationEditor.GizmoAxis axis, boolean fi }; } - private static void drawPoint(MatrixStack matrices, VertexConsumer consumer, Vector3f point, int color, Vec3d camera) { + private static void drawPoint(PoseStack matrices, VertexConsumer consumer, Vector3f point, int color, Vec3 camera) { float size = 0.045f; drawBox( matrices, @@ -226,11 +230,11 @@ private static void drawPoint(MatrixStack matrices, VertexConsumer consumer, Vec ); } - private static void drawBox(MatrixStack matrices, VertexConsumer consumer, + private static void drawBox(PoseStack matrices, VertexConsumer consumer, double minX, double minY, double minZ, double maxX, double maxY, double maxZ, - int color, Vec3d camera) { - Matrix4f matrix = matrices.peek().getPositionMatrix(); + 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); @@ -259,19 +263,19 @@ private static void drawBox(MatrixStack matrices, VertexConsumer consumer, drawLine(matrix, consumer, p100, p110, color); } - private static void drawPolygon(MatrixStack matrices, VertexConsumer consumer, List vertices, int color, Vec3d camera) { + private static void drawPolygon(PoseStack matrices, VertexConsumer consumer, List vertices, int color, Vec3 camera) { if (vertices == null || vertices.size() < 2) return; - Matrix4f matrix = matrices.peek().getPositionMatrix(); + 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.push(); + matrices.pushPose(); matrices.translate(relativeOrigin.x, relativeOrigin.y, relativeOrigin.z); - drawTriangleEdges(matrices.peek().getPositionMatrix(), consumer, geometry.localVertices(), geometry.triangles(), color); - matrices.pop(); + drawTriangleEdges(matrices.last().pose(), consumer, geometry.localVertices(), geometry.triangles(), color); + matrices.popPose(); } catch (IllegalArgumentException ignored) { } } @@ -301,15 +305,15 @@ private static void drawLine(Matrix4f matrix, VertexConsumer consumer, Vector3f Vector3f normal = new Vector3f(to).sub(from); if (normal.lengthSquared() == 0) return; normal.normalize(); - consumer.vertex(matrix, from.x, from.y, from.z).color(color).normal(normal.x, normal.y, normal.z).lineWidth(1.0f); - consumer.vertex(matrix, to.x, to.y, to.z).color(color).normal(normal.x, normal.y, normal.z).lineWidth(1.0f); + 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, Vec3d camera) { + 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, Vec3d camera) { + private static Vector3f relative(Vector3f point, Vec3 camera) { return new Vector3f( (float) (point.x - camera.x), (float) (point.y - camera.y), @@ -317,17 +321,17 @@ private static Vector3f relative(Vector3f point, Vec3d camera) { ); } - private static void drawSphere(MatrixStack matrices, VertexConsumer consumer, Vector3f center, float radius, int color, boolean hemisphere, Vec3d camera) { + 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.push(); + matrices.pushPose(); matrices.translate(relativeCenter.x, relativeCenter.y, relativeCenter.z); if (hemisphere) { drawHemisphere(matrices, consumer, radius, color); - matrices.pop(); + matrices.popPose(); return; } - Matrix4f matrix = matrices.peek().getPositionMatrix(); + Matrix4f matrix = matrices.last().pose(); int segments = 48; for (int i = 0; i < segments; i++) { float a = (float) (Math.PI * 2 * i / segments); @@ -345,11 +349,11 @@ private static void drawSphere(MatrixStack matrices, VertexConsumer consumer, Ve new Vector3f(0, (float) Math.cos(b) * radius, (float) Math.sin(b) * radius), color); } - matrices.pop(); + matrices.popPose(); } - private static void drawHemisphere(MatrixStack matrices, VertexConsumer consumer, float radius, int color) { - Matrix4f matrix = matrices.peek().getPositionMatrix(); + 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); @@ -372,7 +376,7 @@ private static void drawHemisphere(MatrixStack matrices, VertexConsumer consumer } } - private static void drawPlaceholderPreview(MatrixStack matrices, VertexConsumerProvider consumers, List vertices, Vec3d camera) { + private static void drawPlaceholderPreview(PoseStack matrices, MultiBufferSource consumers, List vertices, Vec3 camera) { if (vertices == null || vertices.size() < ScreenGeometry.MIN_VERTICES) return; ScreenGeometry geometry; try { @@ -383,9 +387,9 @@ private static void drawPlaceholderPreview(MatrixStack matrices, VertexConsumerP int previewTextureId = ScreenRenderer.placeholderTextureId(); Vector3f relativeOrigin = geometry.relativeOrigin(camera.x, camera.y, camera.z); - matrices.push(); + matrices.pushPose(); matrices.translate(relativeOrigin.x, relativeOrigin.y, relativeOrigin.z); - Matrix4f matrix = matrices.peek().getPositionMatrix(); + 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(); @@ -395,12 +399,12 @@ private static void drawPlaceholderPreview(MatrixStack matrices, VertexConsumerP drawPreviewTriangle(matrix, backingConsumer, geometry, geometryVertices, triangles, i, bounds, normal, PREVIEW_ALPHA << 24); } - RenderLayer layer = ScreenRenderer.getTranslucentLayer(previewTextureId); + 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.pop(); + matrices.popPose(); } private static void drawPreviewTriangle(Matrix4f matrix, VertexConsumer consumer, ScreenGeometry geometry, diff --git a/src/client/java/com/github/squi2rel/vp/creation/StartupGuideScreen.java b/src/client/java/com/github/squi2rel/vp/creation/StartupGuideScreen.java index 1b8a02f..0c4dec5 100644 --- a/src/client/java/com/github/squi2rel/vp/creation/StartupGuideScreen.java +++ b/src/client/java/com/github/squi2rel/vp/creation/StartupGuideScreen.java @@ -12,12 +12,6 @@ import com.github.squi2rel.vp.video.MpvVideoBackend; import com.github.squi2rel.vp.video.VideoBackends; import com.github.squi2rel.vp.video.VlcDecoder; -import net.minecraft.client.MinecraftClient; -import net.minecraft.client.gui.DrawContext; -import net.minecraft.client.gui.Drawable; -import net.minecraft.client.gui.screen.Screen; -import net.minecraft.text.Text; - import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -25,6 +19,11 @@ import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +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 { @@ -117,14 +116,14 @@ protected void init() { int buttonX = buttonGroupX(); InputRowLayout inputRow = inputRowLayout(); - proxyField = new VpTextFieldWidget(textRenderer, inputRow.proxyFieldX(), contentTop + 4, inputRow.proxyFieldWidth(), CONTROL_HEIGHT, + proxyField = new VpTextFieldWidget(font, inputRow.proxyFieldX(), contentTop + 4, inputRow.proxyFieldWidth(), CONTROL_HEIGHT, VpTexts.tr("label.videoplayer.proxy", "Proxy"), THEME); proxyField.setMaxLength(220); - proxyField.setText(currentProxy()); - ytdlPathField = new VpTextFieldWidget(textRenderer, inputRow.ytdlFieldX(), contentTop + 4, inputRow.ytdlFieldWidth(), CONTROL_HEIGHT, + 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.setText(currentYtdlPath()); + ytdlPathField.setValue(currentYtdlPath()); audioChannelMode = button("", contentRight - audioChannelModeButtonWidth(), contentTop + 37, audioChannelModeButtonWidth(), this::cycleAudioChannelMode); @@ -147,22 +146,22 @@ protected void init() { 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); - addDrawableChild(proxyField); - addDrawableChild(ytdlPathField); - addDrawableChild(audioChannelMode); - addDrawableChild(ytdlpPlatform); - addDrawableChild(ytdlpDownload); - addDrawableChild(ytdlpCopyLink); - addDrawableChild(mpvPlatform); - addDrawableChild(mpvSelect); - addDrawableChild(mpvDownload); - addDrawableChild(mpvCopyLink); - addDrawableChild(vlcPlatform); - addDrawableChild(vlcSelect); - addDrawableChild(vlcDownload); - addDrawableChild(vlcCopyLink); - addDrawableChild(skip); - addDrawableChild(done); + 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(); @@ -178,12 +177,12 @@ public void tick() { } @Override - public void close() { + public void onClose() { finish(); } @Override - public boolean shouldPause() { + public boolean isPauseScreen() { return false; } @@ -203,13 +202,13 @@ public boolean mouseScrolled(double mouseX, double mouseY, double horizontalAmou } @Override - public void render(DrawContext context, int mouseX, int mouseY, float delta) { + public void render(GuiGraphics 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.drawStrokedRectangle(panelLeft, panelTop, panelWidth, panelHeight, THEME.panelBorderColor()); + context.renderOutline(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, @@ -219,7 +218,7 @@ public void render(DrawContext context, int mouseX, int mouseY, float delta) { context.disableScissor(); drawScrollbar(context); - Text line = statusLine(); + Component line = statusLine(); if (!line.getString().isBlank()) { drawText(context, trimToWidth(line, Math.max(40, panelWidth - 48)), panelLeft + 24, panelTop + panelHeight - 42, THEME.secondaryTextColor()); } @@ -229,10 +228,10 @@ public void render(DrawContext context, int mouseX, int mouseY, float delta) { } private VpButtonWidget button(String label, int x, int y, int width, Runnable action) { - return new VpButtonWidget(x, y, width, CONTROL_HEIGHT, Text.literal(label), ignored -> action.run(), THEME); + return new VpButtonWidget(x, y, width, CONTROL_HEIGHT, Component.literal(label), ignored -> action.run(), THEME); } - private VpButtonWidget button(Text label, int x, int y, int width, Runnable action) { + 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); } @@ -319,7 +318,7 @@ private void layoutBackendButtons(VpButtonWidget platform, VpButtonWidget select copyLink.clip(contentLeft, contentTop, contentRight, contentBottom); } - private void drawScrollableContent(DrawContext context, int mouseX, int mouseY, float delta) { + private void drawScrollableContent(GuiGraphics 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()); @@ -349,30 +348,30 @@ private void drawScrollableContent(DrawContext context, int mouseX, int mouseY, renderWidget(mpvCopyLink, context, mouseX, mouseY, delta); } - drawBackend(context, contentLeft, y + vlcStartY(), VideoBackends.VLC, Text.literal("VLC"), vlcAvailable); + 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(DrawContext context, int x, int y, String backend, Text label, boolean available) { + private void drawBackend(GuiGraphics 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); - Text installed = backendInstalled(backend) + Component installed = backendInstalled(backend) ? VpTexts.tr("label.videoplayer.installed", "Installed") : VpTexts.tr("label.videoplayer.not_installed", "Not installed"); - Text sources = count <= 0 + 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); - Text visibleLabel = trimToWidth(label, Math.max(32, textW - 54)); - int statusX = x + Math.max(52, textRenderer.getWidth(visibleLabel) + 8); - Text availability = available + 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 + textRenderer.getWidth(availability) + 8; + 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()); @@ -383,24 +382,24 @@ private boolean backendInstalled(String backend) { return VideoBackends.MPV.equals(VideoBackends.normalize(backend)) ? mpvInstalled : vlcInstalled; } - private void drawYtdlp(DrawContext context, int x, int y) { + private void drawYtdlp(GuiGraphics context, int x, int y) { int color = ytdlpAvailable ? THEME.executionColor() : THEME.errorColor(); int count = ytdlpSources().size(); int textW = Math.max(40, buttonGroupX() - x - GAP); - Text availability = ytdlpDetectionTask != null + 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, Text.literal("yt-dlp"), x, y, THEME.primaryTextColor()); + drawText(context, Component.literal("yt-dlp"), x, y, THEME.primaryTextColor()); drawText(context, availability, x + 48, y, color); - Text detail = ytdlpVersion.isBlank() + 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(DrawContext context) { + private void drawScrollbar(GuiGraphics context) { int viewportHeight = contentBottom - contentTop; int maxScroll = maxContentScroll(); if (maxScroll <= 0 || viewportHeight <= 0) { @@ -437,36 +436,36 @@ private int audioChannelModeButtonWidth() { return Math.min(132, Math.max(88, (contentRight - contentLeft) / 3)); } - private void renderWidget(Drawable widget, DrawContext context, int mouseX, int mouseY, float delta) { + private void renderWidget(Renderable widget, GuiGraphics context, int mouseX, int mouseY, float delta) { if (widget != null) { widget.render(context, mouseX, mouseY, delta); } } - private void drawText(DrawContext context, Text text, int x, int y, int color) { + private void drawText(GuiGraphics context, Component text, int x, int y, int color) { if (THEME.textShadow()) { - context.drawTextWithShadow(textRenderer, text, x, y, color); + context.drawString(font, text, x, y, color); return; } - context.drawText(textRenderer, text, x, y, color, false); + context.drawString(font, text, x, y, color, false); } - private void drawCenteredText(DrawContext context, Text text, int centerX, int y, int color) { - drawText(context, text, centerX - textRenderer.getWidth(text) / 2, y, color); + private void drawCenteredText(GuiGraphics context, Component text, int centerX, int y, int color) { + drawText(context, text, centerX - font.width(text) / 2, y, color); } - private Text trimToWidth(Text text, int maxWidth) { - return Text.literal(trimToWidth(text.getString(), maxWidth)); + 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 (textRenderer.getWidth(value) <= maxWidth) return value; + if (font.width(value) <= maxWidth) return value; String suffix = "..."; - return textRenderer.trimToWidth(value, Math.max(0, maxWidth - textRenderer.getWidth(suffix))) + suffix; + return font.plainSubstrByWidth(value, Math.max(0, maxWidth - font.width(suffix))) + suffix; } - private Text statusLine() { + private Component statusLine() { if (downloadTask == null) return VpTexts.text(status); String backend = activeBackend.equals(YtDlpManager.TOOL_NAME) ? "yt-dlp" @@ -560,7 +559,7 @@ private void startDownload(String backend) { String taskKey = nativeTaskKey(backend, platform); CompletableFuture sharedTask = NATIVE_DOWNLOAD_TASKS.computeIfAbsent(taskKey, ignored -> CompletableFuture.supplyAsync(() -> NativePackageManager.downloadAndInstall(backend, platform, sources, proxy, progress -> - MinecraftClient.getInstance().execute(() -> { + Minecraft.getInstance().execute(() -> { sourceIndex = progress.sourceIndex(); sourceCount = progress.sourceCount(); sourceName = progress.sourceName(); @@ -571,7 +570,7 @@ private void startDownload(String backend) { downloadTask = sharedTask; sharedTask.whenComplete((result, error) -> { NATIVE_DOWNLOAD_TASKS.remove(taskKey, sharedTask); - MinecraftClient.getInstance().execute(() -> { + 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()); @@ -613,7 +612,7 @@ private void startYtdlpDownload() { totalBytes = -1; NativeDownloadConfig config = nativeDownloads(); downloadTask = CompletableFuture.supplyAsync(() -> YtDlpManager.downloadAndInstall(config, selectedYtdlpPlatform, proxy, progress -> - MinecraftClient.getInstance().execute(() -> { + Minecraft.getInstance().execute(() -> { sourceIndex = progress.sourceIndex(); sourceCount = progress.sourceCount(); sourceName = progress.sourceName(); @@ -621,7 +620,7 @@ private void startYtdlpDownload() { totalBytes = progress.totalBytes(); status = progress.message(); }))); - downloadTask.whenComplete((result, error) -> MinecraftClient.getInstance().execute(() -> { + downloadTask.whenComplete((result, error) -> Minecraft.getInstance().execute(() -> { downloadTask = null; if (error != null) { status = VpTranslations.from(error, "error.videoplayer.native.download_failed", "Download failed: %s", @@ -630,7 +629,7 @@ private void startYtdlpDownload() { } status = result.message(); if (result.success()) { - ytdlPathField.setText(""); + ytdlPathField.setValue(""); VideoPlayerClient.config.mpvYtdlPath = ""; VideoPlayerClient.saveConfig(); VideoPlayerClient.applyNativePlatformConfig(); @@ -658,7 +657,7 @@ private void copyYtdlpSourceLink() { private void copyLink(String url) { try { - MinecraftClient.getInstance().keyboard.setClipboard(url); + 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()); @@ -699,7 +698,7 @@ private void cycleAudioChannelMode() { syncButtons(); } - private Text audioChannelModeLabel(AudioChannelMode mode) { + private Component audioChannelModeLabel(AudioChannelMode mode) { return VpTexts.tr( "label.videoplayer.audio_channel_mode." + mode.configValue(), mode == AudioChannelMode.AUTO ? "Auto" : "Stereo" @@ -725,7 +724,7 @@ private String currentProxy() { } private String persistProxy() { - String proxy = proxyField == null ? currentProxy() : proxyField.getText().trim(); + String proxy = proxyField == null ? currentProxy() : proxyField.getValue().trim(); if (VideoPlayerClient.config != null && !Objects.equals(VideoPlayerClient.config.nativeDownloadProxy, proxy)) { VideoPlayerClient.config.nativeDownloadProxy = proxy; VideoPlayerClient.saveConfig(); @@ -740,7 +739,7 @@ private String currentYtdlPath() { } private void persistYtdlPath() { - String path = ytdlPathField == null ? currentYtdlPath() : ytdlPathField.getText().trim(); + 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; @@ -835,12 +834,12 @@ private String platformLabel(String platform) { return platformText(platform).getString(); } - private Text platformText(String platform) { + private Component platformText(String platform) { String arch = NativeDownloadConfig.archFromPlatform(platform); - if (arch.isBlank()) return Text.literal(platform == null ? "" : platform); + if (arch.isBlank()) return Component.literal(platform == null ? "" : platform); return platform.equals(NativePackageManager.platformKey()) ? VpTexts.tr("label.videoplayer.platform_recommended", "%s Recommended", arch) - : Text.literal(arch); + : Component.literal(arch); } private void refreshAvailability() { @@ -854,7 +853,7 @@ private void refreshAvailability() { VlcDecoder.isAvailable(), MpvVideoBackend.isAvailable() )); - availabilityTask.whenComplete((state, error) -> MinecraftClient.getInstance().execute(() -> { + availabilityTask.whenComplete((state, error) -> Minecraft.getInstance().execute(() -> { availabilityTask = null; if (error != null || state == null) { vlcAvailable = false; @@ -877,7 +876,7 @@ private void refreshInstallationState() { mpvPlatform, !VideoPlayerMain.android && NativePackageManager.isInstalled(VideoBackends.MPV, mpvPlatform) )); - installationStateTask.whenComplete((state, error) -> MinecraftClient.getInstance().execute(() -> { + installationStateTask.whenComplete((state, error) -> Minecraft.getInstance().execute(() -> { installationStateTask = null; if (error != null || state == null) return; if (!Objects.equals(state.vlcPlatform(), selectedVlcPlatform) @@ -890,63 +889,16 @@ private void refreshInstallationState() { if (VideoPlayerMain.android) { vlcAvailable = vlcInstalled; mpvAvailable = false; - if (!vlcInstalled && NativeDownloadConfig.ANDROID_ARM64.equals(NativePackageManager.platformKey())) { - startBundledAndroidVlcInstall(); - } } syncButtons(); })); } - private void startBundledAndroidVlcInstall() { - if (!VideoPlayerMain.android - || downloadTask != null - || !NativeDownloadConfig.ANDROID_ARM64.equals(NativePackageManager.platformKey())) { - return; - } - String backend = VideoBackends.VLC; - String platform = NativeDownloadConfig.ANDROID_ARM64; - activeBackend = backend; - status = VpTranslation.of("message.videoplayer.native.prepare_download", "Preparing download"); - sourceIndex = 0; - sourceCount = 0; - sourceName = ""; - bytesRead = 0; - totalBytes = -1; - String taskKey = nativeTaskKey(backend, platform); - CompletableFuture sharedTask = NATIVE_DOWNLOAD_TASKS.computeIfAbsent(taskKey, - ignored -> CompletableFuture.supplyAsync(() -> NativePackageManager.installBundled( - backend, - platform, - NativePackageManager.BUNDLED_ANDROID_VLC_RESOURCE, - NativePackageManager.BUNDLED_ANDROID_VLC_SHA256 - ))); - downloadTask = sharedTask; - sharedTask.whenComplete((result, error) -> { - NATIVE_DOWNLOAD_TASKS.remove(taskKey, sharedTask); - MinecraftClient.getInstance().execute(() -> { - downloadTask = null; - if (error != null) { - status = VpTranslations.from(error, "error.videoplayer.native.bundled_install_failed", - "Bundled native package installation failed: %s", error.getMessage() == null ? "" : error.getMessage()); - syncButtons(); - return; - } - status = result.message(); - if (result.success()) { - markBackendInstalled(backend, true); - vlcAvailable = true; - } - syncButtons(); - }); - }); - } - private void refreshYtdlpAvailability() { if (ytdlpDetectionTask != null) return; - String configured = ytdlPathField == null ? currentYtdlPath() : ytdlPathField.getText().trim(); + String configured = ytdlPathField == null ? currentYtdlPath() : ytdlPathField.getValue().trim(); ytdlpDetectionTask = CompletableFuture.supplyAsync(() -> YtDlpManager.detect(configured)); - ytdlpDetectionTask.whenComplete((detection, error) -> MinecraftClient.getInstance().execute(() -> { + ytdlpDetectionTask.whenComplete((detection, error) -> Minecraft.getInstance().execute(() -> { ytdlpDetectionTask = null; ytdlpAvailable = error == null && detection != null && detection.available(); ytdlpVersion = ytdlpAvailable ? detection.version() : ""; @@ -974,7 +926,8 @@ private BackendRefreshResult refreshBackendAfterRuntimeChange(String backend) { if (VideoPlayerMain.android) { mpvAvailable = false; if (VideoBackends.MPV.equals(backend)) return BackendRefreshResult.RETRY_FAILED; - vlcAvailable = vlcInstalled; + VlcDecoder.resetLoadState(); + vlcAvailable = vlcInstalled && VlcDecoder.isAvailable(); return vlcAvailable ? BackendRefreshResult.RETRY_SUCCEEDED : BackendRefreshResult.RETRY_FAILED; } if (backendLoaded(backend)) return BackendRefreshResult.RESTART_REQUIRED; @@ -1005,8 +958,8 @@ private void finish() { persistProxy(); persistYtdlPath(); VideoPlayerClient.markStartupGuideShown(); - if (client != null) { - client.setScreen(parent); + if (minecraft != null) { + minecraft.setScreen(parent); } } diff --git a/src/client/java/com/github/squi2rel/vp/creation/VideoCreationEditor.java b/src/client/java/com/github/squi2rel/vp/creation/VideoCreationEditor.java index ac97354..4cb3d96 100644 --- a/src/client/java/com/github/squi2rel/vp/creation/VideoCreationEditor.java +++ b/src/client/java/com/github/squi2rel/vp/creation/VideoCreationEditor.java @@ -10,6 +10,7 @@ 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.keybinding.v1.KeyBindingHelper; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; @@ -18,19 +19,18 @@ import net.fabricmc.fabric.api.client.rendering.v1.world.WorldRenderEvents; import net.fabricmc.fabric.api.event.client.player.ClientPreAttackCallback; import net.fabricmc.fabric.api.event.player.UseBlockCallback; -import net.minecraft.client.MinecraftClient; -import net.minecraft.client.option.KeyBinding; -import net.minecraft.client.util.InputUtil; -import net.minecraft.text.Text; -import net.minecraft.util.ActionResult; -import net.minecraft.util.Hand; -import net.minecraft.util.Identifier; -import net.minecraft.util.math.BlockPos; -import net.minecraft.util.math.Box; -import net.minecraft.util.math.Direction; -import net.minecraft.util.math.Vec3d; -import net.minecraft.util.hit.BlockHitResult; -import net.minecraft.util.hit.HitResult; +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; @@ -41,7 +41,7 @@ import java.util.function.Consumer; public final class VideoCreationEditor { - private static final MinecraftClient CLIENT = MinecraftClient.getInstance(); + 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; @@ -51,16 +51,16 @@ public final class VideoCreationEditor { 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.of("videoplayer", "creation_editor"); + 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 KeyBinding openKey; + private KeyMapping openKey; private boolean selecting; private boolean selectingSpherePreset; - private Text status = Text.empty(); + private Component status = Component.empty(); private boolean statusError; private int selectedPointIndex = -1; private GizmoAxis hoveredAxis; @@ -82,11 +82,11 @@ public static void register() { } private void registerInternal() { - openKey = KeyBindingHelper.registerKeyBinding(new KeyBinding( + openKey = KeyBindingHelper.registerKeyBinding(new KeyMapping( "key.videoplayer.creation_editor", - InputUtil.Type.KEYSYM, + InputConstants.Type.KEYSYM, GLFW.GLFW_KEY_V, - KeyBinding.Category.create(Identifier.of("videoplayer", "videoplayer")) + KeyMapping.Category.register(Identifier.fromNamespaceAndPath("videoplayer", "videoplayer")) )); ClientTickEvents.END_CLIENT_TICK.register(this::tick); @@ -96,15 +96,15 @@ private void registerInternal() { return true; }); UseBlockCallback.EVENT.register((player, world, hand, hitResult) -> { - if (!selecting) return ActionResult.PASS; - if (hand == Hand.MAIN_HAND) { + if (!selecting) return InteractionResult.PASS; + if (hand == InteractionHand.MAIN_HAND) { if (draggingAxis != null) { stopDragging(); } else { undoLastPoint(); } } - return ActionResult.FAIL; + return InteractionResult.FAIL; }); ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> clear()); WorldRenderEvents.AFTER_ENTITIES.register(SelectionPreviewRenderer::renderWorld); @@ -115,18 +115,18 @@ private void registerInternal() { ); } - private void tick(MinecraftClient client) { - if (client.player == null || client.world == null) { + private void tick(Minecraft client) { + if (client.player == null || client.level == null) { clear(); return; } - if (selecting && client.currentScreen == null) { + if (selecting && client.screen == null) { tickSelectionInput(); } else { stopDragging(); hoveredAxis = null; } - while (openKey.wasPressed()) { + while (openKey.consumeClick()) { stopDragging(); openConfigScreen(); } @@ -149,14 +149,14 @@ public boolean selectingSpherePreset() { } public boolean active() { - return selecting || CLIENT.currentScreen instanceof VideoManagementScreen || !points.isEmpty(); + return selecting || CLIENT.screen instanceof VideoManagementScreen || !points.isEmpty(); } - public Text openKeyText() { - return Text.keybind(openKey.getId()); + public Component openKeyText() { + return Component.keybind(openKey.getName()); } - public Text status() { + public Component status() { return status; } @@ -182,7 +182,7 @@ public GizmoAxis draggingAxis() { public boolean screenGizmoVisible() { return selecting - && CLIENT.currentScreen == null + && CLIENT.screen == null && !selectingSpherePreset && target() == Target.SCREEN && validSelectedPoint(); @@ -225,7 +225,7 @@ public String modeName() { return modeText().getString(); } - public Text modeText() { + 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) { @@ -287,7 +287,7 @@ public void clear() { selectingSpherePreset = false; points.clear(); resetGizmoState(); - status = Text.empty(); + status = Component.empty(); statusError = false; } @@ -303,7 +303,7 @@ public boolean confirm() { public boolean confirm(Consumer callback) { if (!validateDraft(true)) return false; if (draft.operation == Operation.CREATE_AREA) { - Box box = areaPreview(); + AABB box = areaPreview(); if (box == null) { setStatus("error.videoplayer.select_two_blocks", "Select two blocks first", true); return false; @@ -414,7 +414,7 @@ public String suggestedScreenName(String areaName) { return uniqueScreenName(areaName); } - public Box areaPreview() { + public AABB areaPreview() { if (target() != Target.AREA) return null; if (points.size() >= 2) { return areaBox(points.get(0).blockPos, points.get(1).blockPos); @@ -558,7 +558,7 @@ private boolean validateDraft(boolean requireSelection) { return false; } if (requireSelection) { - Text previousStatus = status; + Component previousStatus = status; boolean previousError = statusError; if (completeVerticesForSubmit() == null) { if (status == previousStatus && statusError == previousError) { @@ -593,7 +593,7 @@ private boolean handleScreenPointClick() { } private boolean handleExistingPointClick() { - if (!selecting || CLIENT.currentScreen != null || points.isEmpty()) return false; + if (!selecting || CLIENT.screen != null || points.isEmpty()) return false; int pointIndex = hitTestPoint(); if (pointIndex < 0) return false; selectedPointIndex = pointIndex; @@ -707,7 +707,7 @@ private void undoLastPoint() { } private BlockHitResult currentBlockHit() { - HitResult target = CLIENT.crosshairTarget; + HitResult target = CLIENT.hitResult; if (target == null || target.getType() != HitResult.Type.BLOCK) return null; return (BlockHitResult) target; } @@ -735,12 +735,12 @@ private void tickSelectionInput() { } private boolean leftMousePressed() { - return GLFW.glfwGetMouseButton(CLIENT.getWindow().getHandle(), GLFW.GLFW_MOUSE_BUTTON_LEFT) == GLFW.GLFW_PRESS; + return GLFW.glfwGetMouseButton(CLIENT.getWindow().handle(), GLFW.GLFW_MOUSE_BUTTON_LEFT) == GLFW.GLFW_PRESS; } private boolean screenPointEditingEnabled() { return selecting - && CLIENT.currentScreen == null + && CLIENT.screen == null && !selectingSpherePreset && target() == Target.SCREEN && !points.isEmpty(); @@ -854,7 +854,7 @@ private void resetGizmoState() { } private Vector3f createDragPlaneNormal(GizmoAxis axis, Vector3f point) { - Vec3d eye = CLIENT.player == null ? new Vec3d(point.x, point.y, point.z) : CLIENT.player.getEyePos(); + 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); @@ -891,49 +891,49 @@ private Vector3f intersectDragPlane(Vector3f planePoint, Vector3f planeNormal) { private Ray currentRay() { if (CLIENT.player == null) return null; - Vec3d direction = CLIENT.player.getRotationVec(1.0f); - if (direction.lengthSquared() <= 0) return null; - return new Ray(CLIENT.player.getEyePos(), direction.normalize()); + 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) { - Vec3d target = toVec3d(point); - Vec3d toTarget = target.subtract(ray.origin); - double along = toTarget.dotProduct(ray.direction); + 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; - Vec3d closest = ray.origin.add(ray.direction.multiply(along)); - return target.squaredDistanceTo(closest); + Vec3 closest = ray.origin.add(ray.direction.scale(along)); + return target.distanceToSqr(closest); } private double distanceRaySegmentSq(Ray ray, Vector3f start, Vector3f end) { - Vec3d rayStart = ray.origin; - Vec3d rayEnd = ray.origin.add(ray.direction.multiply(GIZMO_REACH)); + 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(Vec3d p1, Vec3d q1, Vec3d p2, Vec3d q2) { - Vec3d d1 = q1.subtract(p1); - Vec3d d2 = q2.subtract(p2); - Vec3d r = p1.subtract(p2); - double a = d1.dotProduct(d1); - double e = d2.dotProduct(d2); - double f = d2.dotProduct(r); + 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.squaredDistanceTo(p2); + return p1.distanceToSqr(p2); } if (a <= DRAG_PLANE_EPSILON) { s = 0; t = clamp(f / e, 0, 1); } else { - double c = d1.dotProduct(r); + double c = d1.dot(r); if (e <= DRAG_PLANE_EPSILON) { t = 0; s = clamp(-c / a, 0, 1); } else { - double b = d1.dotProduct(d2); + 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); @@ -951,13 +951,13 @@ private double distanceSegmentSegmentSq(Vec3d p1, Vec3d q1, Vec3d p2, Vec3d q2) } } - Vec3d closest1 = p1.add(d1.multiply(s)); - Vec3d closest2 = p2.add(d2.multiply(t)); - return closest1.squaredDistanceTo(closest2); + Vec3 closest1 = p1.add(d1.scale(s)); + Vec3 closest2 = p2.add(d2.scale(t)); + return closest1.distanceToSqr(closest2); } - private Vec3d toVec3d(Vector3f point) { - return new Vec3d(point.x, point.y, point.z); + private Vec3 toVec3d(Vector3f point) { + return new Vec3(point.x, point.y, point.z); } private double clamp(double value, double min, double max) { @@ -972,14 +972,14 @@ private static float snap(float value) { return Math.round(value * SNAP_SCALE) / SNAP_SCALE; } - private Box areaBox(BlockPos a, BlockPos b) { + 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 Box(minX, minY, minZ, maxX, maxY, maxZ); + return new AABB(minX, minY, minZ, maxX, maxY, maxZ); } private Vector3f[] rectangleQuad(SelectionPoint first, SelectionPoint second) { @@ -1039,7 +1039,7 @@ private Vector3f projectToPlane(Vector3f point, Vector3f planePoint, Vector3f no } private Vector3f directionVector(Direction direction) { - return new Vector3f(direction.getOffsetX(), direction.getOffsetY(), direction.getOffsetZ()); + return new Vector3f(direction.getStepX(), direction.getStepY(), direction.getStepZ()); } private Vector3f rectanglePoint(Vector3f origin, Vector3f right, Vector3f down, float r, float d) { @@ -1164,7 +1164,7 @@ private String uniqueName(String prefix, NameExists exists) { return prefix + System.currentTimeMillis(); } - private void setStatus(Text status, boolean error) { + private void setStatus(Component status, boolean error) { this.status = status; this.statusError = error; } @@ -1174,20 +1174,20 @@ private void setStatus(String key, String fallback, boolean error, Object... arg } private void setStatusWithInput(String key, String fallback, boolean error, Object... args) { - setStatus(Text.translatableWithFallback(key, fallback, args), error); + 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(Text.translatableWithFallback(key, fallback, translatedArgs), error); + setStatus(Component.translatableWithFallback(key, fallback, translatedArgs), error); } - Text leftMouseText() { + Component leftMouseText() { return VpInputTexts.mouseButton(GLFW.GLFW_MOUSE_BUTTON_LEFT); } - Text rightMouseText() { + Component rightMouseText() { return VpInputTexts.mouseButton(GLFW.GLFW_MOUSE_BUTTON_RIGHT); } @@ -1215,7 +1215,7 @@ public String label() { return labelText().getString(); } - public Text labelText() { + public Component labelText() { return this == AREA ? VpTexts.tr("label.videoplayer.target.area", "Area") : VpTexts.tr("label.videoplayer.target.screen", "Screen"); @@ -1235,7 +1235,7 @@ public String label() { return labelText().getString(); } - public Text labelText() { + 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"); @@ -1256,7 +1256,7 @@ public String label() { return labelText().getString(); } - public Text labelText() { + 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"); @@ -1354,17 +1354,17 @@ private SelectionPoint(Vector3f point, BlockPos blockPos, Direction side) { } public static SelectionPoint from(BlockHitResult hit, boolean snap) { - Vec3d pos = hit.getPos(); - Direction side = hit.getSide(); + Vec3 pos = hit.getLocation(); + Direction side = hit.getDirection(); Vector3f point = new Vector3f( - (float) pos.x + side.getOffsetX() * POINT_NORMAL_OFFSET, - (float) pos.y + side.getOffsetY() * POINT_NORMAL_OFFSET, - (float) pos.z + side.getOffsetZ() * POINT_NORMAL_OFFSET + (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().toImmutable(), + hit.getBlockPos().immutable(), side ); } @@ -1374,6 +1374,6 @@ public String format() { } } - private record Ray(Vec3d origin, Vec3d direction) { + private record Ray(Vec3 origin, Vec3 direction) { } } diff --git a/src/client/java/com/github/squi2rel/vp/creation/VideoCreationScreen.java b/src/client/java/com/github/squi2rel/vp/creation/VideoCreationScreen.java index 28d3eb0..7d242de 100644 --- a/src/client/java/com/github/squi2rel/vp/creation/VideoCreationScreen.java +++ b/src/client/java/com/github/squi2rel/vp/creation/VideoCreationScreen.java @@ -4,27 +4,26 @@ import com.github.squi2rel.vp.ClientPacketHandler; import com.github.squi2rel.vp.i18n.VpTexts; import com.github.squi2rel.vp.network.RequestResultStatus; -import net.minecraft.client.gui.DrawContext; -import net.minecraft.client.gui.screen.Screen; -import net.minecraft.client.gui.widget.ButtonWidget; -import net.minecraft.client.gui.widget.TextFieldWidget; -import net.minecraft.text.Text; -import net.minecraft.util.Formatting; - import java.util.List; +import net.minecraft.ChatFormatting; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +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 TextFieldWidget nameField; - private TextFieldWidget sourceField; - private ButtonWidget targetButton; - private ButtonWidget screenModeButton; - private ButtonWidget areaButton; - private ButtonWidget sourceButton; - private ButtonWidget selectionButton; - private ButtonWidget confirmButton; + private EditBox nameField; + private EditBox 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")); @@ -39,81 +38,81 @@ protected void init() { int top = Math.max(24, height / 2 - 112); int row = top + 24; - nameField = new TextFieldWidget(textRenderer, left + 88, row, panelWidth - 88, 20, VpTexts.tr("label.videoplayer.name", "Name")); + nameField = new EditBox(font, left + 88, row, panelWidth - 88, 20, VpTexts.tr("label.videoplayer.name", "Name")); nameField.setMaxLength(VideoScreen.MAX_NAME_BYTES); - nameField.setTextPredicate(VideoScreen::validNameInput); - nameField.setText(draft.name); - addDrawableChild(nameField); + nameField.setFilter(VideoScreen::validNameInput); + nameField.setValue(draft.name); + addRenderableWidget(nameField); row += 28; - targetButton = addDrawableChild(ButtonWidget.builder(Text.empty(), button -> { + 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.setText(draft.name); + nameField.setValue(draft.name); syncButtons(); - }).dimensions(left + 88, row, panelWidth - 88, 20).build()); + }).bounds(left + 88, row, panelWidth - 88, 20).build()); row += 28; - areaButton = addDrawableChild(ButtonWidget.builder(Text.empty(), button -> { + 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(); - }).dimensions(left + 88, row, panelWidth - 88, 20).build()); + }).bounds(left + 88, row, panelWidth - 88, 20).build()); row += 28; - screenModeButton = addDrawableChild(ButtonWidget.builder(Text.empty(), button -> { + screenModeButton = addRenderableWidget(Button.builder(Component.empty(), button -> { draft.screenMode = draft.screenMode.next(); syncButtons(); - }).dimensions(left + 88, row, panelWidth - 88, 20).build()); + }).bounds(left + 88, row, panelWidth - 88, 20).build()); row += 28; - sourceField = new TextFieldWidget(textRenderer, left + 88, row, panelWidth - 168, 20, VpTexts.tr("label.videoplayer.source", "Source")); + sourceField = new EditBox(font, left + 88, row, panelWidth - 168, 20, VpTexts.tr("label.videoplayer.source", "Source")); sourceField.setMaxLength(VideoScreen.MAX_NAME_BYTES); - sourceField.setTextPredicate(VideoScreen::validNameInput); - sourceField.setText(draft.source); - addDrawableChild(sourceField); - sourceButton = addDrawableChild(ButtonWidget.builder(VpTexts.tr("button.videoplayer.select", "Select"), button -> { + 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.getText().trim(); + String current = sourceField.getValue().trim(); int index = names.indexOf(current); draft.source = names.get((index + 1 + names.size()) % names.size()); } - sourceField.setText(draft.source); + sourceField.setValue(draft.source); syncButtons(); - }).dimensions(left + panelWidth - 72, row, 72, 20).build()); + }).bounds(left + panelWidth - 72, row, 72, 20).build()); row += 34; - selectionButton = addDrawableChild(ButtonWidget.builder(VpTexts.tr("button.videoplayer.start_selection", "Start Selection"), button -> { + selectionButton = addRenderableWidget(Button.builder(VpTexts.tr("button.videoplayer.start_selection", "Start Selection"), button -> { copyFieldsToDraft(); editor.beginSelection(draft); - }).dimensions(left, row, 96, 20).build()); - addDrawableChild(ButtonWidget.builder(VpTexts.tr("button.videoplayer.clear_selection", "Clear Selection"), button -> { + }).bounds(left, row, 96, 20).build()); + addRenderableWidget(Button.builder(VpTexts.tr("button.videoplayer.clear_selection", "Clear Selection"), button -> { editor.clearSelection(); syncButtons(); - }).dimensions(left + 104, row, 96, 20).build()); - confirmButton = addDrawableChild(ButtonWidget.builder(VpTexts.tr("button.videoplayer.create", "Create"), button -> { + }).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) close(); + if (result != null && result.status() == RequestResultStatus.OK) onClose(); }); syncButtons(); - }).dimensions(left + panelWidth - 96, row, 96, 20).build()); + }).bounds(left + panelWidth - 96, row, 96, 20).build()); row += 28; - addDrawableChild(ButtonWidget.builder(VpTexts.tr("button.videoplayer.close", "Close"), button -> close()).dimensions(left, row, panelWidth, 20).build()); + addRenderableWidget(Button.builder(VpTexts.tr("button.videoplayer.close", "Close"), button -> onClose()).bounds(left, row, panelWidth, 20).build()); syncButtons(); setInitialFocus(nameField); @@ -126,17 +125,17 @@ public void tick() { } @Override - public void close() { - client.setScreen(null); + public void onClose() { + minecraft.setScreen(null); } @Override - public boolean shouldPause() { + public boolean isPauseScreen() { return false; } @Override - public void render(DrawContext context, int mouseX, int mouseY, float delta) { + public void render(GuiGraphics context, int mouseX, int mouseY, float delta) { renderBackground(context, mouseX, mouseY, delta); int panelWidth = Math.min(320, width - 40); @@ -145,45 +144,45 @@ public void render(DrawContext context, int mouseX, int mouseY, float delta) { int bottom = top + 228; context.fill(left - 12, top - 12, left + panelWidth + 12, bottom, 0xCC101010); - context.drawCenteredTextWithShadow(textRenderer, title, width / 2, top - 2, 0xFFFFFFFF); - context.drawTextWithShadow(textRenderer, VpTexts.tr("label.videoplayer.name", "Name"), left, top + 28, 0xFFE0E0E0); - context.drawTextWithShadow(textRenderer, VpTexts.tr("label.videoplayer.type", "Type"), left, top + 56, 0xFFE0E0E0); - context.drawTextWithShadow(textRenderer, VpTexts.tr("label.videoplayer.area", "Area"), left, top + 84, 0xFFE0E0E0); - context.drawTextWithShadow(textRenderer, VpTexts.tr("label.videoplayer.mode", "Mode"), left, top + 112, 0xFFE0E0E0); - context.drawTextWithShadow(textRenderer, VpTexts.tr("label.videoplayer.source", "Source"), left, top + 140, 0xFFE0E0E0); + context.drawCenteredString(font, title, width / 2, top - 2, 0xFFFFFFFF); + context.drawString(font, VpTexts.tr("label.videoplayer.name", "Name"), left, top + 28, 0xFFE0E0E0); + context.drawString(font, VpTexts.tr("label.videoplayer.type", "Type"), left, top + 56, 0xFFE0E0E0); + context.drawString(font, VpTexts.tr("label.videoplayer.area", "Area"), left, top + 84, 0xFFE0E0E0); + context.drawString(font, VpTexts.tr("label.videoplayer.mode", "Mode"), left, top + 112, 0xFFE0E0E0); + context.drawString(font, VpTexts.tr("label.videoplayer.source", "Source"), left, top + 140, 0xFFE0E0E0); String points = editor.pointProgress(); int statusColor = editor.statusError() ? 0xFFFF5555 : 0xFF55FF55; - context.drawTextWithShadow(textRenderer, VpTexts.tr("label.videoplayer.selection_points", "Selection: %s", points), left, top + 172, 0xFFE0E0E0); - context.drawTextWithShadow(textRenderer, editor.status(), left + 72, top + 172, statusColor); + context.drawString(font, VpTexts.tr("label.videoplayer.selection_points", "Selection: %s", points), left, top + 172, 0xFFE0E0E0); + context.drawString(font, editor.status(), left + 72, top + 172, statusColor); if (draft.target == VideoCreationEditor.Target.SCREEN && draft.areaName.isEmpty()) { - context.drawTextWithShadow(textRenderer, VpTexts.tr("error.videoplayer.need_area_first", "Enter or create an Area first").formatted(Formatting.RED), left, top + 190, 0xFFFF5555); + context.drawString(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.drawTextWithShadow(textRenderer, Text.translatableWithFallback( + context.drawString(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.drawTextWithShadow(textRenderer, VpTexts.tr("hint.videoplayer.area_two_blocks", "Area uses two blocks to create a bounding box"), left, top + 190, 0xFFB0B0B0); + context.drawString(font, VpTexts.tr("hint.videoplayer.area_two_blocks", "Area uses two blocks to create a bounding box"), left, top + 190, 0xFFB0B0B0); } super.render(context, mouseX, mouseY, delta); } private void copyFieldsToDraft() { - draft.name = nameField == null ? draft.name : nameField.getText().trim(); - draft.source = sourceField == null ? draft.source : sourceField.getText().trim(); + 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.getText().equals(draft.name)) { - draft.name = nameField.getText().trim(); + if (nameField != null && !nameField.getValue().equals(draft.name)) { + draft.name = nameField.getValue().trim(); } - if (sourceField != null && !sourceField.getText().equals(draft.source)) { - draft.source = sourceField.getText().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; diff --git a/src/client/java/com/github/squi2rel/vp/creation/VideoManagementScreen.java b/src/client/java/com/github/squi2rel/vp/creation/VideoManagementScreen.java index f11403c..3cfe56a 100644 --- a/src/client/java/com/github/squi2rel/vp/creation/VideoManagementScreen.java +++ b/src/client/java/com/github/squi2rel/vp/creation/VideoManagementScreen.java @@ -30,14 +30,6 @@ import com.github.squi2rel.vp.video.VideoBackends; import com.github.squi2rel.vp.video.VideoPlayer; import com.github.squi2rel.vp.video.VideoScreen; -import net.minecraft.client.gui.DrawContext; -import net.minecraft.client.gui.Drawable; -import net.minecraft.client.gui.Click; -import net.minecraft.client.gui.screen.Screen; -import net.minecraft.client.gui.widget.ClickableWidget; -import net.minecraft.client.gui.widget.TextFieldWidget; -import net.minecraft.text.Text; -import net.minecraft.util.Formatting; import org.joml.Vector3f; import java.util.ArrayList; @@ -51,6 +43,14 @@ import java.util.function.IntConsumer; import java.util.function.IntFunction; import java.util.function.Predicate; +import net.minecraft.ChatFormatting; +import net.minecraft.client.gui.GuiGraphics; +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; @@ -109,30 +109,30 @@ public class VideoManagementScreen extends Screen implements ServerStateScreen { 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 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 TextFieldWidget nameField; - private TextFieldWidget sourceField; - private TextFieldWidget urlField; - private TextFieldWidget customKeyField; - private TextFieldWidget customValueField; - private TextFieldWidget sphereCenterXField; - private TextFieldWidget sphereCenterYField; - private TextFieldWidget sphereCenterZField; - private TextFieldWidget sphereRadiusField; - private TextFieldWidget sphereLatField; - private TextFieldWidget sphereLonField; - private TextFieldWidget sphereRotXField; - private TextFieldWidget sphereRotYField; - private TextFieldWidget sphereRotZField; + 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; @@ -149,7 +149,7 @@ public class VideoManagementScreen extends Screen implements ServerStateScreen { private int biliQualityOverlayViewportTop; private int biliQualityOverlayViewportBottom; private int biliQualityOverlayContentHeight; - private ClickableWidget activeDanmakuOverlayWidget; + private AbstractWidget activeDanmakuOverlayWidget; private ClientVideoScreen playbackProgressDragScreen; private boolean playbackProgressPausedBeforeDrag; private boolean playbackProgressPauseApplied; @@ -283,7 +283,7 @@ protected void init() { } @Override - public boolean shouldPause() { + public boolean isPauseScreen() { return false; } @@ -304,10 +304,10 @@ public void tick() { } @Override - public void close() { + public void onClose() { endPlaybackProgressDrag(); if (diagnosticsReview != null) diagnosticsReview.close(); - client.setScreen(null); + minecraft.setScreen(null); } @Override @@ -317,12 +317,12 @@ public void removed() { } @Override - public void renderBackground(DrawContext context, int mouseX, int mouseY, float delta) { + public void renderBackground(GuiGraphics context, int mouseX, int mouseY, float delta) { context.fill(0, 0, width, height, VpUiRenderer.withAlpha(THEME.canvasBackgroundColor(), 0xCC)); } @Override - public void render(DrawContext context, int mouseX, int mouseY, float delta) { + public void render(GuiGraphics context, int mouseX, int mouseY, float delta) { renderBackground(context, mouseX, mouseY, delta); int margin = 14; int sidebarX = margin; @@ -336,7 +336,7 @@ public void render(DrawContext context, int mouseX, int mouseY, float delta) { 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.drawText(textRenderer, title, sidebarX, 20, THEME.primaryTextColor(), false); + context.drawString(font, title, sidebarX, 20, THEME.primaryTextColor(), false); drawSidebarLabels(context, sidebarX); renderClippedDrawables(context, areaScrollDrawables, mouseX, mouseY, delta, @@ -355,16 +355,16 @@ public void render(DrawContext context, int mouseX, int mouseY, float delta) { } @Override - public boolean mouseClicked(Click click, boolean doubleClick) { + public boolean mouseClicked(MouseButtonEvent click, boolean doubleClick) { if (overlayOpen() && !insideActiveOverlay(click.x(), click.y())) { closeOverlays(); activeDanmakuOverlayWidget = null; - clearAndInit(); + rebuildWidgets(); return true; } if (insideActiveOverlay(click.x(), click.y())) { for (int i = danmakuOverlayWidgets.size() - 1; i >= 0; i--) { - ClickableWidget widget = danmakuOverlayWidgets.get(i); + AbstractWidget widget = danmakuOverlayWidgets.get(i); if (widget.mouseClicked(click, doubleClick)) { activeDanmakuOverlayWidget = widget; setFocused(widget); @@ -378,7 +378,7 @@ public boolean mouseClicked(Click click, boolean doubleClick) { } @Override - public boolean mouseDragged(Click click, double deltaX, double deltaY) { + public boolean mouseDragged(MouseButtonEvent click, double deltaX, double deltaY) { if (activeDanmakuOverlayWidget != null) { return activeDanmakuOverlayWidget.mouseDragged(click, deltaX, deltaY) || insideActiveOverlay(click.x(), click.y()); } @@ -386,7 +386,7 @@ public boolean mouseDragged(Click click, double deltaX, double deltaY) { } @Override - public boolean mouseReleased(Click click) { + public boolean mouseReleased(MouseButtonEvent click) { if (activeDanmakuOverlayWidget != null) { boolean handled = activeDanmakuOverlayWidget.mouseReleased(click); activeDanmakuOverlayWidget = null; @@ -782,7 +782,7 @@ private void initCreateEdit(int x, int y, int width) { 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); - Text surfaceLabel = draft.operation == VideoCreationEditor.Operation.CREATE_SCREEN + 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, () -> { @@ -923,13 +923,13 @@ private void initPlayback(int x, int y, int width) { 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.getText().isBlank()) return; - ClientPacketHandler.request(screen.getScreen(), urlField.getText().trim(), permissionFeedback(button)); + 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 && client != null) client.setScreen(new IdlePlayListScreen(this, screen)); + if (screen != null && minecraft != null) minecraft.setScreen(new IdlePlayListScreen(this, screen)); }); idleList.active = selected != null && canScreen(VideoPermissionAction.SET_IDLE_PLAY, selectedPlaybackScreen()); row += BUTTON_ROW_GAP; @@ -1028,7 +1028,7 @@ private void addPlaybackBottomControls(int x, int width) { biliLocalQualityOverlayOpen = false; biliScreenQualityOverlayOpen = false; youtubeScreenQualityOverlay = false; - clearAndInit(); + rebuildWidgets(); }).selected(danmakuOverlayOpen); danmakuSettings.active = true; if (danmakuOverlayOpen) { @@ -1044,7 +1044,7 @@ private void addPlaybackBottomControls(int x, int width) { biliLocalQualityOverlayOpen = false; biliScreenQualityOverlayOpen = false; youtubeScreenQualityOverlay = false; - clearAndInit(); + rebuildWidgets(); }).selected(ccSubtitleOverlayOpen || (playbackScreen != null && playbackScreen.subtitles().hasSelectedTrack())); ccSubtitle.active = playbackScreen != null && playbackScreen.subtitles().availableForCurrentVideo(); if (ccSubtitleOverlayOpen && ccSubtitle.active) { @@ -1060,7 +1060,7 @@ private void addPlaybackBottomControls(int x, int width) { ccSubtitleOverlayOpen = false; biliScreenQualityOverlayOpen = false; youtubeScreenQualityOverlay = false; - clearAndInit(); + rebuildWidgets(); }).selected(biliLocalQualityOverlayOpen); quality.active = currentBiliInfo(playbackScreen) != null || currentYouTubeInfo(playbackScreen) != null; if (biliLocalQualityOverlayOpen && quality.active) { @@ -1070,7 +1070,7 @@ private void addPlaybackBottomControls(int x, int width) { } VpButtonWidget pin = squareButton("钉", pinX, row, () -> { playbackPreviewPinned = !playbackPreviewPinned; - clearAndInit(); + rebuildWidgets(); }).selected(playbackPreviewPinned); ClientVideoScreen pinnedScreen = selectedPlaybackScreen(); pin.active = pinnedScreen != null && pinnedScreen.player != null; @@ -1085,7 +1085,7 @@ private void initScreenSettings(int x, int y, int 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 = client != null && client.player != null && client.getNetworkHandler() != null; + reconnect.active = minecraft != null && minecraft.player != null && minecraft.getConnection() != null; } private void initDiagnostics(int x, int y, int width) { @@ -1135,7 +1135,7 @@ private void initDisplay(int x, int y, int width) { if (open) biliQualityOverlayScroll = 0; danmakuOverlayOpen = false; biliLocalQualityOverlayOpen = false; - clearAndInit(); + rebuildWidgets(); }).selected(biliScreenQualityOverlayOpen && !youtubeScreenQualityOverlay); biliQuality.active = screen != null && canScreen(VideoPermissionAction.SET_METADATA, screen); if (biliScreenQualityOverlayOpen && !youtubeScreenQualityOverlay && biliQuality.active) { @@ -1151,7 +1151,7 @@ private void initDisplay(int x, int y, int width) { if (open) biliQualityOverlayScroll = 0; danmakuOverlayOpen = false; biliLocalQualityOverlayOpen = false; - clearAndInit(); + rebuildWidgets(); }).selected(biliScreenQualityOverlayOpen && youtubeScreenQualityOverlay); youtubeQuality.active = screen != null && canScreen(VideoPermissionAction.SET_METADATA, screen); if (biliScreenQualityOverlayOpen && youtubeScreenQualityOverlay && youtubeQuality.active) { @@ -1163,7 +1163,7 @@ private void initDisplay(int x, int y, int width) { 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) client.setScreen(new VideoMappingScreen(this, selected)); + if (selected != null && selected.fill) minecraft.setScreen(new VideoMappingScreen(this, selected)); }); mapping.active = screen != null && screen.fill && screen.vertices.size() >= 3 && canScreen(VideoPermissionAction.SET_METADATA, screen); row += FORM_ROW_GAP; @@ -1214,12 +1214,12 @@ private void initMeta(int x, int y, int width) { remove.active = screen != null && canScreen(VideoPermissionAction.SET_METADATA, screen); } - private void drawSidebarLabels(DrawContext context, int x) { + private void drawSidebarLabels(GuiGraphics context, int x) { drawLabel(context, "Area", x, sidebarAreaLabelY(), THEME.primaryTextColor()); drawLabel(context, "Screen", x, sidebarScreenLabelY(), THEME.primaryTextColor()); } - private void drawTabContent(DrawContext context, int x, int y, int width, int mouseX, int mouseY) { + private void drawTabContent(GuiGraphics 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); @@ -1228,7 +1228,7 @@ private void drawTabContent(DrawContext context, int x, int y, int width, int mo } } - private void renderContent(DrawContext context, int mouseX, int mouseY, float delta, int x, int y, int width) { + private void renderContent(GuiGraphics 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)) { @@ -1237,20 +1237,20 @@ private void renderContent(DrawContext context, int mouseX, int mouseY, float de context.disableScissor(); } - private void renderClippedDrawables(DrawContext context, List drawables, int mouseX, int mouseY, float delta, + private void renderClippedDrawables(GuiGraphics 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(DrawContext context, List drawables, int mouseX, int mouseY, float delta) { - for (Drawable drawable : drawables) { + private void renderDrawables(GuiGraphics context, List drawables, int mouseX, int mouseY, float delta) { + for (Renderable drawable : drawables) { drawable.render(context, mouseX, mouseY, delta); } } - private void renderActiveOverlay(DrawContext context, int mouseX, int mouseY, float delta) { + private void renderActiveOverlay(GuiGraphics context, int mouseX, int mouseY, float delta) { if (!overlayOpen() || danmakuOverlayW <= 0 || danmakuOverlayH <= 0) { return; } @@ -1263,7 +1263,7 @@ private void renderActiveOverlay(DrawContext context, int mouseX, int mouseY, fl } } - private void renderDanmakuOverlay(DrawContext context, int mouseX, int mouseY, float delta) { + private void renderDanmakuOverlay(GuiGraphics context, int mouseX, int mouseY, float delta) { if (!danmakuOverlayOpen || tab != Tab.PLAYBACK || danmakuOverlayW <= 0 || danmakuOverlayH <= 0) { return; } @@ -1281,14 +1281,14 @@ private void renderDanmakuOverlay(DrawContext context, int mouseX, int mouseY, f renderDrawables(context, danmakuOverlayDrawables, mouseX, mouseY, delta); } - private void renderBiliQualityOverlay(DrawContext context, int mouseX, int mouseY, float delta) { + private void renderBiliQualityOverlay(GuiGraphics 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()); - Text title; + Component title; if (biliLocalQualityOverlayOpen) { title = currentYouTubeInfo(selectedPlaybackScreen()) != null ? VpTexts.tr("label.videoplayer.youtube_quality.local", "YouTube Quality") @@ -1307,7 +1307,7 @@ private void renderBiliQualityOverlay(DrawContext context, int mouseX, int mouse drawScrollbar(context, right - 4, biliQualityOverlayViewportTop, biliQualityOverlayViewportBottom, biliQualityOverlayScroll, biliQualityOverlayContentHeight); } - private void renderCcSubtitleOverlay(DrawContext context, int mouseX, int mouseY, float delta) { + private void renderCcSubtitleOverlay(GuiGraphics context, int mouseX, int mouseY, float delta) { if (!ccSubtitleOverlayOpen) { return; } @@ -1365,8 +1365,8 @@ private boolean scrollBiliQualityOverlay(int delta) { private void rebuildBiliQualityOverlayAtCurrentPosition() { int anchorRight = danmakuOverlayX + danmakuOverlayW; int anchorY = danmakuOverlayY; - for (ClickableWidget widget : danmakuOverlayWidgets) { - remove(widget); + for (AbstractWidget widget : danmakuOverlayWidgets) { + removeWidget(widget); } danmakuOverlayDrawables.clear(); danmakuOverlayWidgets.clear(); @@ -1384,7 +1384,7 @@ private void rebuildBiliQualityOverlayAtCurrentPosition() { } } - private void drawScrollbar(DrawContext context, int x, int top, int bottom, int scroll, int contentHeight) { + private void drawScrollbar(GuiGraphics 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) { @@ -1399,7 +1399,7 @@ private void drawScrollbar(DrawContext context, int x, int top, int bottom, int VpUiRenderer.drawBox(context, x, thumbY, 4, thumbHeight, thumbColor, thumbColor); } - private void drawCreateEdit(DrawContext context, int x, int y) { + private void drawCreateEdit(GuiGraphics context, int x, int y) { VideoCreationEditor.Draft draft = editor.draft(); int contentW = Math.max(180, width - x - 14); int row = y + 44; @@ -1455,7 +1455,7 @@ private void drawCreateEdit(DrawContext context, int x, int y) { trackContentBottom(statusY + 10); } - private void drawPlayback(DrawContext context, int x, int y, int mouseX, int mouseY) { + private void drawPlayback(GuiGraphics context, int x, int y, int mouseX, int mouseY) { if (showPlaybackProgressPreview(mouseX, mouseY)) { drawPlaybackProgressPreview(context, x); trackContentBottom(contentViewportBottom()); @@ -1562,7 +1562,7 @@ private long clampPlaybackPreviewProgress(long progress) { return Math.clamp(progress, 0, maxPreview); } - private boolean drawPlaybackProgressPreview(DrawContext context, int x) { + private boolean drawPlaybackProgressPreview(GuiGraphics context, int x) { ClientVideoScreen screen = selectedPlaybackScreen(); if (screen == null || screen.player == null) return false; int textureId = screen.displayTextureId(); @@ -1587,13 +1587,13 @@ private boolean drawPlaybackProgressPreview(DrawContext context, int x) { 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.drawStrokedRectangle(previewX - 1, previewY - 1, previewW + 2, previewH + 2, THEME.panelBorderColor()); + context.renderOutline(previewX - 1, previewY - 1, previewW + 2, previewH + 2, THEME.panelBorderColor()); return true; } - private void drawPlaybackTexture(DrawContext context, ClientVideoScreen screen, int textureId, int x, int y, int width, int height) { + private void drawPlaybackTexture(GuiGraphics context, ClientVideoScreen screen, int textureId, int x, int y, int width, int height) { float u2 = screen != null && screen.stereo3d ? 0.5f : 1f; - context.drawTexturedQuad( + context.blit( ScreenRenderer.textureIdentifier(textureId), x, y, @@ -1606,11 +1606,11 @@ private void drawPlaybackTexture(DrawContext context, ClientVideoScreen screen, ); } - private void drawScreenSettings(DrawContext context, int x, int y, int width) { + private void drawScreenSettings(GuiGraphics context, int x, int y, int width) { VideoConnectionDiagnostics.Snapshot connection = VideoPlayerClient.connectionSnapshot(); int contentW = Math.max(180, width); - Text address = VpTexts.tr("label.videoplayer.server_address", "Server: %s", connectionAddress(connection)); - Text status = connectionStatus(connection); + 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()); @@ -1623,7 +1623,7 @@ private void drawScreenSettings(DrawContext context, int x, int y, int width) { drawMeta(context, x, y + SCREEN_SETTINGS_META_CONTENT_Y, width); } - private void drawDiagnostics(DrawContext context, int x, int y, int width) { + private void drawDiagnostics(GuiGraphics context, int x, int y, int width) { int contentW = Math.max(180, width); int row = y + 30; ClientVideoScreen screen = selectedScreen(); @@ -1677,12 +1677,12 @@ private void drawDiagnostics(DrawContext context, int x, int y, int width) { trackContentBottom(row + 4); } - private int drawDiagnosticsLine(DrawContext context, int x, int y, int width, Text text, int color) { + private int drawDiagnosticsLine(GuiGraphics 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(DrawContext context, int x, int y, int width) { + private int drawAudioLevelGraph(GuiGraphics context, int x, int y, int width) { AudioLevelSnapshot level = diagnosticsReview == null ? AudioLevelSnapshot.unsupported() : diagnosticsReview.currentLevel(); @@ -1832,7 +1832,7 @@ private void updateDiagnosticsReview() { } } - private Text diagnosticsMuteText() { + private Component diagnosticsMuteText() { boolean muted = diagnosticsReview != null && diagnosticsReview.muted(); return VpTexts.tr("label.videoplayer.review_mute", "Review Mute: %s", onOff(muted).getString()); } @@ -1869,7 +1869,7 @@ private String connectionAddress(VideoConnectionDiagnostics.Snapshot connection) return connection.address(); } - private Text connectionStatus(VideoConnectionDiagnostics.Snapshot connection) { + 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())); @@ -1897,7 +1897,7 @@ private int connectionStatusColor(VideoConnectionDiagnostics.State state) { }; } - private void drawDisplay(DrawContext context, int x, int y) { + private void drawDisplay(GuiGraphics context, int x, int y) { int contentW = Math.max(180, width - x - 14); int scaleSliderW = actionButtonWidth(contentW, 2); int idleImageRow = y + FORM_ROW_GAP; @@ -1916,7 +1916,7 @@ private void drawDisplay(DrawContext context, int x, int y) { trackContentBottom(scaleRow + CONTROL_HEIGHT + 4); } - private void drawMeta(DrawContext context, int x, int y, int width) { + private void drawMeta(GuiGraphics 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()); @@ -1943,79 +1943,79 @@ private void drawMeta(DrawContext context, int x, int y, int width) { trackContentBottom(row + 4); } - private void drawLabel(DrawContext context, String label, int x, int y, int color) { - drawLabel(context, Text.literal(label), x, y, color); + private void drawLabel(GuiGraphics context, String label, int x, int y, int color) { + drawLabel(context, Component.literal(label), x, y, color); } - private void drawLabel(DrawContext context, Text label, int x, int y, int color) { + private void drawLabel(GuiGraphics context, Component label, int x, int y, int color) { if (THEME.textShadow()) { - context.drawTextWithShadow(textRenderer, label, x, y, color); + context.drawString(font, label, x, y, color); return; } - context.drawText(textRenderer, label, x, y, color, false); + context.drawString(font, label, x, y, color, false); } private String trimToWidth(String text, int maxWidth) { String value = text == null ? "" : text; - if (textRenderer.getWidth(value) <= maxWidth) return value; + if (font.width(value) <= maxWidth) return value; String suffix = "..."; - return textRenderer.trimToWidth(value, Math.max(0, maxWidth - textRenderer.getWidth(suffix))) + suffix; + return font.plainSubstrByWidth(value, Math.max(0, maxWidth - font.width(suffix))) + suffix; } - private TextFieldWidget textField(int x, int y, int width, String text, int maxLength) { + private EditBox textField(int x, int y, int width, String text, int maxLength) { return textField(x, y, width, text, maxLength, value -> true); } - private TextFieldWidget textField(int x, int y, int width, String text, int maxLength, Predicate predicate) { - VpTextFieldWidget field = new VpTextFieldWidget(textRenderer, x, y, Math.max(40, width), CONTROL_HEIGHT, Text.empty(), THEME); + 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.setTextPredicate(predicate); - field.setText(text == null ? "" : text); - addDrawableChild(field); + 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(Text.literal(label), x, y, width, action); + return button(Component.literal(label), x, y, width, action); } - private VpButtonWidget button(Text label, int x, int y, int width, Runnable 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); - addDrawableChild(button); + addRenderableWidget(button); registerDrawable(button, y, CONTROL_HEIGHT); return button; } private VpButtonWidget button(String label, int x, int y, int width, Consumer action) { - return button(Text.literal(label), x, y, width, action); + return button(Component.literal(label), x, y, width, action); } - private VpButtonWidget button(Text label, int x, int y, int width, Consumer 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); - addDrawableChild(button); + addRenderableWidget(button); registerDrawable(button, y, CONTROL_HEIGHT); return button; } private VpButtonWidget squareButton(String label, int x, int y, Runnable action) { - return squareButton(Text.literal(label), x, y, action); + return squareButton(Component.literal(label), x, y, action); } - private VpButtonWidget squareButton(Text label, int x, int y, Runnable 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); - addDrawableChild(button); + addRenderableWidget(button); registerDrawable(button, y, CONTROL_HEIGHT); return button; } private VpButtonWidget danmakuOverlayButton(String label, int x, int y, int width, Consumer action) { - return danmakuOverlayButton(Text.literal(label), x, y, width, action); + return danmakuOverlayButton(Component.literal(label), x, y, width, action); } - private VpButtonWidget danmakuOverlayButton(Text label, int x, int y, int width, Consumer 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); - addDrawableChild(button); + addRenderableWidget(button); danmakuOverlayDrawables.add(button); danmakuOverlayWidgets.add(button); return button; @@ -2024,17 +2024,17 @@ private VpButtonWidget danmakuOverlayButton(Text label, int x, int y, int width, 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 -> Text.literal(messageFormatter.apply(v)), THEME); - addDrawableChild(slider); + (VpSliderWidget.TextFormatter) v -> Component.literal(messageFormatter.apply(v)), THEME); + addRenderableWidget(slider); danmakuOverlayDrawables.add(slider); danmakuOverlayWidgets.add(slider); return slider; } - private VpSliderWidget danmakuOverlaySlider(Text label, int x, int y, int width, int value, + 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); - addDrawableChild(slider); + addRenderableWidget(slider); danmakuOverlayDrawables.add(slider); danmakuOverlayWidgets.add(slider); return slider; @@ -2203,7 +2203,7 @@ private void initCcSubtitleOverlay(int anchorRight, int anchorY, int minX, int m choices.add(new SubtitleChoice(option.key(), ccSubtitleOptionText(option), true)); } if (subtitles.options().isEmpty()) { - Text label = subtitles.catalogLoaded() + 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)); @@ -2239,7 +2239,7 @@ private void initCcSubtitleOverlay(int anchorRight, int anchorY, int minX, int m int buttonY = rowY + i * (CONTROL_HEIGHT + BILI_QUALITY_OVERLAY_BUTTON_GAP); VpButtonWidget button = danmakuOverlayButton(choice.label(), innerX, buttonY, buttonW, ignored -> { subtitles.select(choice.key()); - clearAndInit(); + rebuildWidgets(); }); button.clip(innerX, biliQualityOverlayViewportTop, innerX + buttonW, biliQualityOverlayViewportBottom); button.selected(Objects.equals(choice.key(), selected)); @@ -2248,7 +2248,7 @@ private void initCcSubtitleOverlay(int anchorRight, int anchorY, int minX, int m } private void initQualityOverlay(int anchorRight, int anchorY, int minX, int maxX, List options, - int selected, IntConsumer selector, IntFunction labeler) { + int selected, IntConsumer selector, IntFunction labeler) { if (options == null || options.isEmpty()) { closeOverlays(); return; @@ -2283,7 +2283,7 @@ private void initQualityOverlay(int anchorRight, int anchorY, int minX, int maxX } } - private VpButtonWidget addDanmakuToggle(Text label, int x, int y, int width, BooleanSupplier getter, Consumer setter) { + 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(); @@ -2317,8 +2317,8 @@ private boolean showDanmakuDensityControls() { private void rebuildDanmakuOverlayAtCurrentPosition() { int anchorRight = danmakuOverlayX + danmakuOverlayW; int anchorY = danmakuOverlayY; - for (ClickableWidget widget : danmakuOverlayWidgets) { - remove(widget); + for (AbstractWidget widget : danmakuOverlayWidgets) { + removeWidget(widget); } danmakuOverlayDrawables.clear(); danmakuOverlayWidgets.clear(); @@ -2360,7 +2360,7 @@ private int sliderValueToDanmakuScale(int value) { return Math.clamp(50 + Math.round(Math.clamp(value, 0, 100) * 120.0f / 100.0f), 50, 170); } - private Text localBiliQualityButtonText() { + private Component localBiliQualityButtonText() { if (currentYouTubeInfo(selectedPlaybackScreen()) != null) { List available = currentAvailableYouTubeQualities(); int quality = displayedLocalYouTubeQuality(available); @@ -2373,17 +2373,17 @@ private Text localBiliQualityButtonText() { return VpTexts.tr("label.videoplayer.bili_quality.local_value", "Bili: %s", biliQualityText(quality).getString()); } - private Text ccSubtitleButtonText(ClientVideoScreen screen) { + 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 Text ccSubtitleOptionText(ClientSubtitleController.Option option) { + private Component ccSubtitleOptionText(ClientSubtitleController.Option option) { String label = option == null ? "" : option.label(); if (label == null || label.isBlank()) label = option == null ? "" : option.language(); - return Text.literal(label == null || label.isBlank() ? "CC" : label); + return Component.literal(label == null || label.isBlank() ? "CC" : label); } private String ccSubtitleOverlaySignature() { @@ -2399,23 +2399,23 @@ private String ccSubtitleOverlaySignature() { return builder.toString(); } - private Text screenBiliQualityButtonText(ClientVideoScreen screen) { + 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 Text screenYouTubeQualityButtonText(ClientVideoScreen screen) { + 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 Text biliQualityText(int quality) { + private Component biliQualityText(int quality) { return VpTexts.tr(BiliQuality.translationKey(quality), BiliQuality.fallbackLabel(quality)); } - private Text youtubeQualityText(int quality) { + private Component youtubeQualityText(int quality) { return VpTexts.tr(YouTubeQuality.translationKey(quality), YouTubeQuality.fallbackLabel(quality)); } @@ -2469,7 +2469,7 @@ private void selectLocalBiliQuality(int quality) { VideoPlayerClient.config.bilibiliQuality = BiliQuality.normalizeClient(quality); VideoPlayerClient.saveConfig(); biliLocalQualityOverlayOpen = false; - clearAndInit(); + rebuildWidgets(); ClientPacketHandler.reloadQualityPlayback(screen); } @@ -2482,7 +2482,7 @@ private void selectLocalYouTubeQuality(int quality) { VideoPlayerClient.config.youtubeQuality = YouTubeQuality.normalizeClient(quality); VideoPlayerClient.saveConfig(); biliLocalQualityOverlayOpen = false; - clearAndInit(); + rebuildWidgets(); ClientPacketHandler.reloadQualityPlayback(screen); } @@ -2514,7 +2514,7 @@ private void closeOnOk(VpButtonWidget button, ClientPacketHandler.RequestResult return; } if (result != null && result.status() == RequestResultStatus.OK) { - close(); + onClose(); } } @@ -2534,28 +2534,28 @@ private VpSliderWidget slider(String label, int x, int y, int width, int value, return slider(label, x, y, width, value, action, ignored -> VideoPlayerClient.saveConfig()); } - private VpSliderWidget slider(Text label, int x, int y, int width, int value, IntConsumer action) { + 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); - addDrawableChild(slider); + addRenderableWidget(slider); registerDrawable(slider, y, CONTROL_HEIGHT); return slider; } - private VpSliderWidget slider(Text label, int x, int y, int width, int value, IntConsumer action, IntConsumer commit) { + 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); - addDrawableChild(slider); + 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 -> Text.literal(messageFormatter.apply(v)), THEME); - addDrawableChild(slider); + (VpSliderWidget.TextFormatter) v -> Component.literal(messageFormatter.apply(v)), THEME); + addRenderableWidget(slider); registerDrawable(slider, y, CONTROL_HEIGHT); return slider; } @@ -2567,12 +2567,12 @@ private VpProgressSliderWidget progressSlider(int x, int y, int width, Runnable dragStart, Runnable dragEnd) { VpProgressSliderWidget slider = new VpProgressSliderWidget(x, y, Math.max(80, width), PLAYBACK_PROGRESS_HEIGHT, source, preview, commit, dragStart, dragEnd, THEME); - addDrawableChild(slider); + addRenderableWidget(slider); registerDrawable(slider, y, PLAYBACK_PROGRESS_HEIGHT); return slider; } - private void registerDrawable(Drawable drawable, int y, int height) { + private void registerDrawable(Renderable drawable, int y, int height) { switch (widgetGroup) { case FIXED -> fixedDrawables.add(drawable); case AREA_SCROLL -> { @@ -2593,7 +2593,7 @@ private void registerDrawable(Drawable drawable, int y, int height) { } } - private void applyClip(Drawable drawable, WidgetGroup group) { + private void applyClip(Renderable drawable, WidgetGroup group) { int left = clipLeft(group); int top = clipTop(group); int right = clipRight(group); @@ -2690,13 +2690,13 @@ private void clearSphereFields() { private void copyCreateEditFieldsToDraft() { VideoCreationEditor.Draft draft = editor.draft(); if (nameField != null && draft.operation != VideoCreationEditor.Operation.EDIT_SCREEN_GEOMETRY) { - draft.name = nameField.getText().trim(); + 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.getText().trim(); + if (sourceField != null) draft.source = sourceField.getValue().trim(); Float centerX = parseFloat(sphereCenterXField); Float centerY = parseFloat(sphereCenterYField); Float centerZ = parseFloat(sphereCenterZField); @@ -2718,7 +2718,7 @@ private void copyCreateEditFieldsToDraft() { draft.target = draft.operation.target(); } - private Text selectionButtonText() { + private Component selectionButtonText() { return editor.selecting() ? VpTexts.tr("button.videoplayer.cancel_selection", "Cancel Selection") : VpTexts.tr("button.videoplayer.start_selection", "Start Selection"); @@ -2804,13 +2804,13 @@ private Vector3f defaultSphereCenter() { private void cycleSource() { List sources = sourceNames(); if (sources.isEmpty()) { - if (sourceField != null) sourceField.setText(""); + if (sourceField != null) sourceField.setValue(""); return; } - String current = sourceField == null ? "" : sourceField.getText().trim(); + 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.setText(next); + if (sourceField != null) sourceField.setValue(next); editor.draft().source = next; } @@ -2836,7 +2836,7 @@ private void saveScreenConfig(VpButtonWidget button) { if (screen == null) return; copyCreateEditFieldsToDraft(); VideoCreationEditor.Draft draft = editor.draft(); - String source = sourceField == null ? "" : sourceField.getText().trim(); + 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; @@ -2890,9 +2890,9 @@ private void sendDisplayConfig(ClientVideoScreen screen, VideoScreen displayConf ClientPacketHandler.updateScreen(screen, copyVertices(screen.vertices), safe(screen.source), displayConfig, callback); } - private void sendLocalError(Text message) { - if (client != null && client.player != null) { - client.player.sendMessage(message.copy().formatted(Formatting.RED), false); + private void sendLocalError(Component message) { + if (minecraft != null && minecraft.player != null) { + minecraft.player.displayClientMessage(message.copy().withStyle(ChatFormatting.RED), false); } } @@ -2954,14 +2954,14 @@ private void toggleMeta(VpButtonWidget button, String key, boolean defaultValue) private void setCustomMeta(VpButtonWidget button, boolean remove) { ClientVideoScreen screen = selectedScreen(); if (screen == null || customKeyField == null) return; - String key = customKeyField.getText().trim(); + 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.getText()); + MetaValue value = MetaValue.parse(customMetaType, customValueField == null ? "" : customValueField.getValue()); setMetadata(screen, key, value, permissionFeedback(button)); } catch (Exception ignored) { } @@ -2977,7 +2977,7 @@ private void setMetadata(ClientVideoScreen screen, String key, MetaValue value, ClientPacketHandler.setMetadata(screen, key, value, result -> { if (result != null && result.status() == RequestResultStatus.OK && VideoPlayerClient.screens.contains(screen) - && client.currentScreen instanceof VideoManagementScreen) { + && minecraft.screen instanceof VideoManagementScreen) { reopen(null); } if (callback != null) callback.accept(result); @@ -2996,7 +2996,7 @@ private void removeMetadata(ClientVideoScreen screen, String key, Consumer { if (result != null && result.status() == RequestResultStatus.OK && VideoPlayerClient.screens.contains(screen) - && client.currentScreen instanceof VideoManagementScreen) { + && minecraft.screen instanceof VideoManagementScreen) { reopen(null); } if (callback != null) callback.accept(result); @@ -3142,7 +3142,7 @@ private String metadataSignature() { 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.getText().trim(); + 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() @@ -3178,13 +3178,13 @@ private void reopenPreservingDraft() { private void reopen(ClientVideoScreen focusedScreen, boolean preserveDraftDisplay) { if (diagnosticsReview != null) diagnosticsReview.beginHandoff(); if (focusedScreen != null) { - client.setScreen(new VideoManagementScreen(editor, focusedScreen, tab, + minecraft.setScreen(new VideoManagementScreen(editor, focusedScreen, tab, danmakuOverlayOpen, biliLocalQualityOverlayOpen, biliScreenQualityOverlayOpen, youtubeScreenQualityOverlay, ccSubtitleOverlayOpen, playbackPreviewPinned, diagnosticsReview)); return; } - client.setScreen(new VideoManagementScreen( + minecraft.setScreen(new VideoManagementScreen( editor, tab, selectedAreaName, @@ -3206,7 +3206,7 @@ private void reopen(ClientVideoScreen focusedScreen, boolean preserveDraftDispla )); } - private Text operationLabel(VideoCreationEditor.Operation operation) { + 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"); @@ -3214,21 +3214,21 @@ private Text operationLabel(VideoCreationEditor.Operation operation) { }; } - private Text boolLabel(ClientVideoScreen screen, String key, boolean defaultValue) { + private Component boolLabel(ClientVideoScreen screen, String key, boolean defaultValue) { boolean value = screen == null ? defaultValue : screen.metadata.getBool(key, defaultValue); return onOff(value); } - private Text onOff(boolean value) { + private Component onOff(boolean value) { return value ? VpTexts.tr("label.videoplayer.on", "On") : VpTexts.tr("label.videoplayer.off", "Off"); } - private Text danmakuSpeedLabel(int index) { + 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 Text danmakuDensityLabel(int index) { + 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]); } @@ -3256,20 +3256,20 @@ private String format(float value) { return String.format(Locale.ROOT, "%.4f", value); } - private Float parseFloat(TextFieldWidget field) { + private Float parseFloat(EditBox field) { if (field == null) return null; try { - float value = Float.parseFloat(field.getText().trim()); + float value = Float.parseFloat(field.getValue().trim()); return Float.isFinite(value) ? value : null; } catch (Exception e) { return null; } } - private Integer parseInt(TextFieldWidget field) { + private Integer parseInt(EditBox field) { if (field == null) return null; try { - return Integer.parseInt(field.getText().trim()); + return Integer.parseInt(field.getValue().trim()); } catch (Exception e) { return null; } @@ -3290,7 +3290,7 @@ private enum WidgetGroup { CONTENT_SCROLL } - private record SubtitleChoice(String key, Text label, boolean active) { + private record SubtitleChoice(String key, Component label, boolean active) { } private enum Tab { @@ -3307,7 +3307,7 @@ private enum Tab { this.fallback = fallback; } - Text label() { + Component label() { return VpTexts.tr(key, fallback); } } diff --git a/src/client/java/com/github/squi2rel/vp/creation/VideoMappingScreen.java b/src/client/java/com/github/squi2rel/vp/creation/VideoMappingScreen.java index 1df8f41..9802d16 100644 --- a/src/client/java/com/github/squi2rel/vp/creation/VideoMappingScreen.java +++ b/src/client/java/com/github/squi2rel/vp/creation/VideoMappingScreen.java @@ -11,10 +11,6 @@ import com.github.squi2rel.vp.video.MetaValue; import com.github.squi2rel.vp.video.ScreenGeometry; import com.github.squi2rel.vp.video.ScreenMetadata; -import net.minecraft.client.gui.Click; -import net.minecraft.client.gui.DrawContext; -import net.minecraft.client.gui.screen.Screen; -import net.minecraft.text.Text; import org.joml.Vector2f; import org.joml.Vector3f; @@ -22,7 +18,10 @@ import java.util.HashSet; import java.util.List; import java.util.function.Consumer; - +import net.minecraft.client.gui.GuiGraphics; +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 { @@ -97,57 +96,57 @@ protected void init() { this.keepAspect = !this.keepAspect; button.setMessage(keepAspectText()); }, THEME); - addDrawableChild(keepAspectButton); - button(VpTexts.tr("button.videoplayer.close", "Close"), startX + 260, bottom, 72, this::close); + addRenderableWidget(keepAspectButton); + button(VpTexts.tr("button.videoplayer.close", "Close"), startX + 260, bottom, 72, this::onClose); } @Override - public boolean shouldPause() { + public boolean isPauseScreen() { return false; } @Override - public void close() { + public void onClose() { saveIfDirty(); - client.setScreen(parent); + minecraft.setScreen(parent); } @Override - public void render(DrawContext context, int mouseX, int mouseY, float delta) { + public void render(GuiGraphics context, int mouseX, int mouseY, float delta) { computeLayout(); renderBackground(context, mouseX, mouseY, delta); drawChrome(context); super.render(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, Text.literal(screen.name), imageX, imageY - 14, THEME.secondaryTextColor()); - Text controls = Text.translatableWithFallback( + 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, Text.literal(textRenderer.trimToWidth(controls, Math.max(1, width - 36)).getString()), 18, height - 46, THEME.secondaryTextColor()); + 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.drawStrokedRectangle(previewX - 1, previewY - 1, previewW + 2, previewH + 2, THEME.panelBorderColor()); + context.renderOutline(previewX - 1, previewY - 1, previewW + 2, previewH + 2, THEME.panelBorderColor()); drawFrame(context, imageX, imageY, imageW, imageH); drawTexture(context); - context.drawStrokedRectangle(imageX - 1, imageY - 1, imageW + 2, imageH + 2, THEME.panelBorderColor()); + context.renderOutline(imageX - 1, imageY - 1, imageW + 2, imageH + 2, THEME.panelBorderColor()); drawPolygon(context); drawSelectionBox(context); drawHandles(context, mouseX, mouseY); } @Override - public void renderBackground(DrawContext context, int mouseX, int mouseY, float delta) { + public void renderBackground(GuiGraphics context, int mouseX, int mouseY, float delta) { context.fill(0, 0, width, height, VpUiRenderer.withAlpha(THEME.canvasBackgroundColor(), 0xE6)); } @Override - public boolean mouseClicked(Click click, boolean doubleClick) { + public boolean mouseClicked(MouseButtonEvent click, boolean doubleClick) { double mouseX = click.x(); double mouseY = click.y(); int button = click.button(); @@ -159,7 +158,7 @@ public boolean mouseClicked(Click click, boolean doubleClick) { return true; } int handle = handleAt(mouseX, mouseY); - if (handle >= 0 && click.buttonInfo().hasCtrlOrCmd()) { + if (handle >= 0 && click.buttonInfo().hasControlDownWithQuirk()) { toggleSelectedVertex(handle); return true; } @@ -205,7 +204,7 @@ public boolean mouseClicked(Click click, boolean doubleClick) { } @Override - public boolean mouseDragged(Click click, double deltaX, double deltaY) { + public boolean mouseDragged(MouseButtonEvent click, double deltaX, double deltaY) { double mouseX = click.x(); double mouseY = click.y(); int button = click.button(); @@ -227,7 +226,7 @@ public boolean mouseDragged(Click click, double deltaX, double deltaY) { return true; } if (button == 0 && dragMode == DragMode.EDGE && draggingEdge >= 0) { - moveEdge(mouseX, mouseY, click.buttonInfo().hasShift()); + moveEdge(mouseX, mouseY, click.buttonInfo().hasShiftDown()); return true; } if (button == 0 && dragMode == DragMode.PAN) { @@ -239,7 +238,7 @@ public boolean mouseDragged(Click click, double deltaX, double deltaY) { if ((button == 0 || button == 1) && dragMode == DragMode.ROTATE) { float after = angleAt(mouseX, mouseY, dragStartCenter); float delta = normalizedAngle(after - rotationStartAngle); - if (click.buttonInfo().hasShift()) { + if (click.buttonInfo().hasShiftDown()) { float step = (float) Math.toRadians(15); delta = Math.round(delta / step) * step; } @@ -259,7 +258,7 @@ public boolean mouseDragged(Click click, double deltaX, double deltaY) { } @Override - public boolean mouseReleased(Click click) { + public boolean mouseReleased(MouseButtonEvent click) { double mouseX = click.x(); double mouseY = click.y(); int button = click.button(); @@ -299,7 +298,7 @@ public boolean mouseScrolled(double mouseX, double mouseY, double horizontalAmou return super.mouseScrolled(mouseX, mouseY, horizontalAmount, verticalAmount); } - private Text keepAspectText() { + 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()); } @@ -412,8 +411,8 @@ private void computeLayout() { imageY = TOP_HEIGHT + Math.max(0, (maxH - imageH) / 2); } - private void drawTexture(DrawContext context) { - context.drawTexturedQuad( + private void drawTexture(GuiGraphics context) { + context.blit( ScreenRenderer.textureIdentifier(screen.displayTextureId()), imageX, imageY, @@ -426,54 +425,54 @@ private void drawTexture(DrawContext context) { ); } - private void drawChrome(DrawContext context) { + private void drawChrome(GuiGraphics 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(DrawContext context, int x, int y, int frameWidth, int frameHeight) { + private void drawFrame(GuiGraphics 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(DrawContext context, Text text, int x, int y, int color) { + private void drawLabel(GuiGraphics context, Component text, int x, int y, int color) { if (THEME.textShadow()) { - context.drawTextWithShadow(textRenderer, text, x, y, color); + context.drawString(font, text, x, y, color); return; } - context.drawText(textRenderer, text, x, y, color, false); + context.drawString(font, text, x, y, color, false); } - private void drawCenteredLabel(DrawContext context, Text text, int centerX, int y, int color) { - drawLabel(context, text, centerX - textRenderer.getWidth(text) / 2, y, color); + private void drawCenteredLabel(GuiGraphics 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, Text.literal(label), b -> action.run(), THEME); - addDrawableChild(button); + 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(Text label, int x, int y, int width, Runnable 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); - addDrawableChild(button); + 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, Text.literal(label), action, THEME); - addDrawableChild(button); + VpButtonWidget button = new VpButtonWidget(x, y, Math.max(34, width), CONTROL_HEIGHT, Component.literal(label), action, THEME); + addRenderableWidget(button); return button; } - private VpButtonWidget button(Text label, int x, int y, int width, Consumer 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); - addDrawableChild(button); + addRenderableWidget(button); return button; } - private void drawPreview(DrawContext context) { + private void drawPreview(GuiGraphics context) { if (uvs.size() < 3) return; ScreenGeometry geometry; try { @@ -554,7 +553,7 @@ private PreviewVertex3d rotatePreviewVertex(Vector3f vertex, Vector3f center) { return new PreviewVertex3d(yawX, pitchY, pitchZ); } - private void drawPreview3dTexture(DrawContext context, ScreenGeometry geometry, ArrayList projected) { + private void drawPreview3dTexture(GuiGraphics context, ScreenGeometry geometry, ArrayList projected) { int[] triangles = geometry.triangles(); ArrayList vertices = new ArrayList<>(triangles.length); for (int i = 0; i < triangles.length; i += 3) { @@ -570,7 +569,7 @@ private void addPreview3dVertex(ArrayList vertices, Ar vertices.add(new ScreenRenderer.GuiVertex(point.x, point.y, uv.x, uv.y, 0xFFFFFFFF)); } - private void drawPreview3dOutline(DrawContext context, ScreenGeometry geometry, ArrayList projected) { + private void drawPreview3dOutline(GuiGraphics context, ScreenGeometry geometry, ArrayList projected) { int count = geometry.vertices().size(); for (int i = 0; i < count; i++) { PreviewVertex3d a = projected.get(i); @@ -579,7 +578,7 @@ private void drawPreview3dOutline(DrawContext context, ScreenGeometry geometry, } } - private void drawPolygon(DrawContext context) { + private void drawPolygon(GuiGraphics context) { if (uvs.size() < 2) return; for (int i = 0; i < uvs.size(); i++) { Vector2f a = uvs.get(i); @@ -589,7 +588,7 @@ private void drawPolygon(DrawContext context) { drawTriangleGuides(context); } - private void drawTriangleGuides(DrawContext context) { + private void drawTriangleGuides(GuiGraphics context) { if (uvs.size() < 4) return; ScreenGeometry geometry; try { @@ -606,7 +605,7 @@ private void drawTriangleGuides(DrawContext context) { } } - private void drawTriangleGuideEdge(DrawContext context, int from, int to) { + private void drawTriangleGuideEdge(GuiGraphics context, int from, int to) { int size = uvs.size(); if (from == to) return; int diff = Math.abs(from - to); @@ -616,7 +615,7 @@ private void drawTriangleGuideEdge(DrawContext context, int from, int to) { drawLine(context, toX(a), toY(a), toX(b), toY(b), TRIANGLE_GUIDE_COLOR, GUIDE_LINE_WIDTH); } - private void drawHandles(DrawContext context, int mouseX, int mouseY) { + private void drawHandles(GuiGraphics context, int mouseX, int mouseY) { pruneSelection(); for (int i = 0; i < uvs.size(); i++) { Vector2f uv = uvs.get(i); @@ -630,13 +629,13 @@ private void drawHandles(DrawContext context, int mouseX, int mouseY) { String label = String.valueOf(i + 1); int labelX = x + 7; int labelY = y - 5; - context.fill(labelX - 2, labelY - 1, labelX + textRenderer.getWidth(label) + 2, labelY + 10, + context.fill(labelX - 2, labelY - 1, labelX + font.width(label) + 2, labelY + 10, VpUiRenderer.withAlpha(THEME.panelBackgroundColor(), 0xCC)); - drawLabel(context, Text.literal(label), labelX, labelY, THEME.primaryTextColor()); + drawLabel(context, Component.literal(label), labelX, labelY, THEME.primaryTextColor()); } } - private void drawSelectionBox(DrawContext context) { + private void drawSelectionBox(GuiGraphics 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)); @@ -644,21 +643,21 @@ private void drawSelectionBox(DrawContext context) { 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.drawStrokedRectangle(x1, y1, x2 - x1, y2 - y1, THEME.executionColor()); + context.renderOutline(x1, y1, x2 - x1, y2 - y1, THEME.executionColor()); } - private void drawLine(DrawContext context, float x1, float y1, float x2, float y2, int color, float width) { + private void drawLine(GuiGraphics 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.getMatrices().pushMatrix(); - context.getMatrices().translate(x1, y1); - context.getMatrices().rotate((float) Math.atan2(dy, dx)); + 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.getMatrices().popMatrix(); + context.pose().popMatrix(); } private int handleAt(double mouseX, double mouseY) { diff --git a/src/client/java/com/github/squi2rel/vp/creation/VpButtonWidget.java b/src/client/java/com/github/squi2rel/vp/creation/VpButtonWidget.java index b894dce..b7c529a 100644 --- a/src/client/java/com/github/squi2rel/vp/creation/VpButtonWidget.java +++ b/src/client/java/com/github/squi2rel/vp/creation/VpButtonWidget.java @@ -1,18 +1,17 @@ package com.github.squi2rel.vp.creation; import com.github.squi2rel.vp.i18n.VpTexts; -import net.minecraft.client.MinecraftClient; -import net.minecraft.client.font.TextRenderer; -import net.minecraft.client.gui.Click; -import net.minecraft.client.gui.DrawContext; -import net.minecraft.client.gui.screen.narration.NarrationMessageBuilder; -import net.minecraft.client.gui.widget.ClickableWidget; -import net.minecraft.client.input.KeyInput; -import net.minecraft.text.Text; - import java.util.function.Consumer; - -class VpButtonWidget extends ClickableWidget { +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; +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; @@ -22,10 +21,10 @@ class VpButtonWidget extends ClickableWidget { private int clipTop; private int clipRight; private int clipBottom; - private Text temporaryMessage; + private Component temporaryMessage; private long temporaryMessageUntil; - VpButtonWidget(int x, int y, int width, int height, Text message, Consumer onPress, VpUiTheme theme) { + 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; @@ -51,12 +50,12 @@ VpButtonWidget clip(int left, int top, int right, int bottom) { } void showTemporaryLabel(String label, long millis) { - temporaryMessage = Text.literal(label == null ? "" : label); + temporaryMessage = Component.literal(label == null ? "" : label); temporaryMessageUntil = System.currentTimeMillis() + Math.max(0, millis); } - void showTemporaryLabel(Text label, long millis) { - temporaryMessage = label == null ? Text.empty() : label; + void showTemporaryLabel(Component label, long millis) { + temporaryMessage = label == null ? Component.empty() : label; temporaryMessageUntil = System.currentTimeMillis() + Math.max(0, millis); } @@ -70,47 +69,47 @@ public boolean isMouseOver(double mouseX, double mouseY) { } @Override - protected void renderWidget(DrawContext context, int mouseX, int mouseY, float delta) { + protected void renderWidget(GuiGraphics 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); - TextRenderer textRenderer = MinecraftClient.getInstance().textRenderer; + Font textRenderer = Minecraft.getInstance().font; drawButtonText(context, textRenderer, textColor); } @Override - public void onClick(Click click, boolean doubleClick) { + public void onClick(MouseButtonEvent click, boolean doubleClick) { onPress.accept(this); } @Override - public boolean keyPressed(KeyInput input) { - if (!active || !visible || !input.isEnterOrSpace()) return false; + public boolean keyPressed(KeyEvent input) { + if (!active || !visible || !input.isSelection()) return false; onPress.accept(this); return true; } @Override - protected void appendClickableNarrations(NarrationMessageBuilder builder) { - appendDefaultNarrations(builder); + protected void updateWidgetNarration(NarrationElementOutput builder) { + defaultButtonNarrationText(builder); } - private void drawButtonText(DrawContext context, TextRenderer textRenderer, int color) { + private void drawButtonText(GuiGraphics 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.getWidth(label) > innerWidth ? textRenderer.trimToWidth(label, innerWidth) : label; - Text visibleText = Text.literal(visibleLabel); - int textWidth = textRenderer.getWidth(visibleLabel); + 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.fontHeight) / 2); + int textY = getY() + Math.max(1, (getHeight() - textRenderer.lineHeight) / 2); if (theme.textShadow()) { - context.drawTextWithShadow(textRenderer, visibleText, textX, textY, color); + context.drawString(textRenderer, visibleText, textX, textY, color); return; } - context.drawText(textRenderer, visibleText, textX, textY, color, false); + context.drawString(textRenderer, visibleText, textX, textY, color, false); } private int fillColor() { @@ -130,7 +129,7 @@ private int fillColor() { return base; } - private Text displayMessage() { + private Component displayMessage() { if (temporaryMessage != null && System.currentTimeMillis() < temporaryMessageUntil) { return temporaryMessage; } diff --git a/src/client/java/com/github/squi2rel/vp/creation/VpProgressSliderWidget.java b/src/client/java/com/github/squi2rel/vp/creation/VpProgressSliderWidget.java index 6cffd35..3701ffa 100644 --- a/src/client/java/com/github/squi2rel/vp/creation/VpProgressSliderWidget.java +++ b/src/client/java/com/github/squi2rel/vp/creation/VpProgressSliderWidget.java @@ -1,18 +1,17 @@ package com.github.squi2rel.vp.creation; import com.github.squi2rel.vp.i18n.VpTexts; -import net.minecraft.client.MinecraftClient; -import net.minecraft.client.font.TextRenderer; -import net.minecraft.client.gui.Click; -import net.minecraft.client.gui.DrawContext; -import net.minecraft.client.gui.widget.SliderWidget; -import net.minecraft.client.input.KeyInput; -import net.minecraft.text.Text; - import java.util.function.LongConsumer; import java.util.function.Supplier; - -class VpProgressSliderWidget extends SliderWidget { +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; +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; @@ -26,7 +25,7 @@ class VpProgressSliderWidget extends SliderWidget { 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, Text.empty(), 0.0); + super(x, y, Math.max(80, width), height, Component.empty(), 0.0); this.source = source; this.onPreview = onPreview; this.onCommit = onCommit; @@ -54,7 +53,7 @@ && mouseX < getX() + getWidth() } @Override - public boolean mouseClicked(Click click, boolean doubleClick) { + public boolean mouseClicked(MouseButtonEvent click, boolean doubleClick) { updateState(); if (!active || click.button() != 0 || !isMouseOver(click.x(), click.y())) { return false; @@ -72,7 +71,7 @@ public boolean mouseClicked(Click click, boolean doubleClick) { } @Override - public boolean mouseDragged(Click click, double deltaX, double deltaY) { + public boolean mouseDragged(MouseButtonEvent click, double deltaX, double deltaY) { if (!dragging || !active || click.button() != 0) { return false; } @@ -80,7 +79,7 @@ public boolean mouseDragged(Click click, double deltaX, double deltaY) { } @Override - public void onRelease(Click click) { + public void onRelease(MouseButtonEvent click) { boolean wasDragging = dragging; super.onRelease(click); if (!wasDragging) return; @@ -90,7 +89,7 @@ public void onRelease(Click click) { } @Override - public boolean keyPressed(KeyInput input) { + public boolean keyPressed(KeyEvent input) { updateState(); if (!active) return false; boolean handled = super.keyPressed(input); @@ -113,15 +112,15 @@ protected void updateMessage() { return; } if (!state.seekable) { - setMessage(Text.literal(formatDuration(state.total, state.total))); + setMessage(Component.literal(formatDuration(state.total, state.total))); return; } long progress = dragging ? dragProgress : state.progress; - setMessage(Text.literal(formatDuration(progress, state.total) + "/" + formatDuration(state.total, state.total))); + setMessage(Component.literal(formatDuration(progress, state.total) + "/" + formatDuration(state.total, state.total))); } @Override - public void renderWidget(DrawContext context, int mouseX, int mouseY, float delta) { + public void renderWidget(GuiGraphics 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(); @@ -139,7 +138,7 @@ public void renderWidget(DrawContext context, int mouseX, int mouseY, float delt 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)); - TextRenderer textRenderer = MinecraftClient.getInstance().textRenderer; + Font textRenderer = Minecraft.getInstance().font; int textColor = state.available ? theme.secondaryTextColor() : VpUiRenderer.blend(theme.secondaryTextColor(), theme.canvasBackgroundColor(), 0.45f); drawProgressText(context, textRenderer, textColor); } @@ -161,14 +160,14 @@ private void previewCurrentValue() { onPreview.accept(dragProgress); } - private void drawProgressText(DrawContext context, TextRenderer textRenderer, int textColor) { + private void drawProgressText(GuiGraphics 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.getWidth(totalText); + int totalX = getX() + getWidth() - 4 - textRenderer.width(totalText); drawText(context, textRenderer, totalText, totalX, textColor); if (!state.seekable) return; @@ -178,18 +177,18 @@ private void drawProgressText(DrawContext context, TextRenderer textRenderer, in drawText(context, textRenderer, visibleProgressText, getX() + 4, textColor); } - private void drawText(DrawContext context, TextRenderer textRenderer, String text, int x, int color) { + private void drawText(GuiGraphics context, Font textRenderer, String text, int x, int color) { if (text == null || text.isEmpty()) return; if (theme.textShadow()) { - context.drawTextWithShadow(textRenderer, text, x, getY() + 2, color); + context.drawString(textRenderer, text, x, getY() + 2, color); return; } - context.drawText(textRenderer, text, x, getY() + 2, color, false); + context.drawString(textRenderer, text, x, getY() + 2, color, false); } - private static String trimText(TextRenderer textRenderer, String text, int maxWidth) { + private static String trimText(Font textRenderer, String text, int maxWidth) { if (maxWidth <= 0) return ""; - return textRenderer.getWidth(text) > maxWidth ? textRenderer.trimToWidth(text, maxWidth) : text; + return textRenderer.width(text) > maxWidth ? textRenderer.plainSubstrByWidth(text, maxWidth) : text; } private static String formatDuration(long millis, long totalMillis) { diff --git a/src/client/java/com/github/squi2rel/vp/creation/VpSliderWidget.java b/src/client/java/com/github/squi2rel/vp/creation/VpSliderWidget.java index a72fda7..ba850f0 100644 --- a/src/client/java/com/github/squi2rel/vp/creation/VpSliderWidget.java +++ b/src/client/java/com/github/squi2rel/vp/creation/VpSliderWidget.java @@ -1,16 +1,15 @@ package com.github.squi2rel.vp.creation; -import net.minecraft.client.MinecraftClient; -import net.minecraft.client.font.TextRenderer; -import net.minecraft.client.gui.Click; -import net.minecraft.client.gui.DrawContext; -import net.minecraft.client.input.KeyInput; -import net.minecraft.client.gui.widget.SliderWidget; -import net.minecraft.text.Text; - import java.util.function.IntConsumer; - -class VpSliderWidget extends SliderWidget { +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; +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; @@ -24,17 +23,17 @@ class VpSliderWidget extends SliderWidget { 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 -> Text.literal(label + ": " + value1 + "%"), 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, Text label, int value, + 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, Text.empty(), Math.clamp(value, 0, 100) / 100.0); + 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; @@ -58,7 +57,7 @@ public boolean isMouseOver(double mouseX, double mouseY) { } @Override - public void renderWidget(DrawContext context, int mouseX, int mouseY, float delta) { + public void renderWidget(GuiGraphics 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); @@ -75,17 +74,17 @@ public void renderWidget(DrawContext context, int mouseX, int mouseY, float delt int knobX = trackX + Math.clamp(fillW, 0, trackW) - 2; context.fill(knobX, trackY - 2, knobX + 4, trackY + 4, theme.primaryTextColor()); - TextRenderer textRenderer = MinecraftClient.getInstance().textRenderer; + Font textRenderer = Minecraft.getInstance().font; String text = getMessage().getString(); - String visibleText = textRenderer.getWidth(text) > getWidth() - 8 ? textRenderer.trimToWidth(text, getWidth() - 8) : text; + 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.drawTextWithShadow(textRenderer, visibleText, textX, textY, textColor); + context.drawString(textRenderer, visibleText, textX, textY, textColor); return; } - context.drawText(textRenderer, visibleText, textX, textY, textColor, false); + context.drawString(textRenderer, visibleText, textX, textY, textColor, false); } @Override @@ -103,13 +102,13 @@ protected void applyValue() { } @Override - public void onRelease(Click click) { + public void onRelease(MouseButtonEvent click) { super.onRelease(click); onCommit.accept(intValue); } @Override - public boolean keyPressed(KeyInput input) { + public boolean keyPressed(KeyEvent input) { boolean handled = super.keyPressed(input); if (handled) onCommit.accept(intValue); return handled; @@ -124,6 +123,6 @@ private boolean insideClip(double mouseX, double mouseY) { @FunctionalInterface interface TextFormatter { - Text apply(int value); + Component apply(int value); } } diff --git a/src/client/java/com/github/squi2rel/vp/creation/VpTextFieldWidget.java b/src/client/java/com/github/squi2rel/vp/creation/VpTextFieldWidget.java index a0ec6aa..7e69d7f 100644 --- a/src/client/java/com/github/squi2rel/vp/creation/VpTextFieldWidget.java +++ b/src/client/java/com/github/squi2rel/vp/creation/VpTextFieldWidget.java @@ -1,18 +1,18 @@ package com.github.squi2rel.vp.creation; -import net.minecraft.client.font.TextRenderer; -import net.minecraft.client.gui.Click; -import net.minecraft.client.gui.DrawContext; -import net.minecraft.client.gui.widget.TextFieldWidget; -import net.minecraft.client.input.CharInput; -import net.minecraft.client.input.KeyInput; -import net.minecraft.text.Text; - -class VpTextFieldWidget extends TextFieldWidget { +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.EditBox; +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 EditBox { private static final int PADDING_X = 4; private static final int PADDING_Y = 4; - private final TextRenderer textRenderer; + private final Font textRenderer; private int frameX; private int frameY; private final int frameWidth; @@ -26,17 +26,17 @@ class VpTextFieldWidget extends TextFieldWidget { private int clipRight; private int clipBottom; - VpTextFieldWidget(TextRenderer textRenderer, int x, int y, int width, int height, Text message, VpUiTheme theme) { - super(textRenderer, x + PADDING_X, y + PADDING_Y, Math.max(1, width - PADDING_X * 2), textRenderer.fontHeight, message); + 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; - setDrawsBackground(false); - setEditableColor(theme.primaryTextColor()); - setUneditableColor(VpUiRenderer.blend(theme.secondaryTextColor(), theme.canvasBackgroundColor(), 0.42f)); + setBordered(false); + setTextColor(theme.primaryTextColor()); + setTextColorUneditable(VpUiRenderer.blend(theme.secondaryTextColor(), theme.canvasBackgroundColor(), 0.42f)); } VpTextFieldWidget clip(int left, int top, int right, int bottom) { @@ -61,7 +61,7 @@ public void setY(int y) { } @Override - public void renderWidget(DrawContext context, int mouseX, int mouseY, float delta) { + public void renderWidget(GuiGraphics 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) { @@ -72,46 +72,46 @@ public void renderWidget(DrawContext context, int mouseX, int mouseY, float delt } @Override - public void setText(String text) { - super.setText(text); + public void setValue(String text) { + super.setValue(text); syncSelectionState(); } @Override - public void write(String text) { - super.write(text); + public void insertText(String text) { + super.insertText(text); syncSelectionState(); } @Override - public void setSelectionStart(int selectionStart) { - super.setSelectionStart(selectionStart); + public void setCursorPosition(int selectionStart) { + super.setCursorPosition(selectionStart); syncSelectionStart(); } @Override - public void setSelectionEnd(int selectionEnd) { - super.setSelectionEnd(selectionEnd); + public void setHighlightPos(int selectionEnd) { + super.setHighlightPos(selectionEnd); this.selectionEnd = clampIndex(selectionEnd); updateVisibleStart(this.selectionEnd); } @Override - public boolean keyPressed(KeyInput input) { + public boolean keyPressed(KeyEvent input) { boolean handled = super.keyPressed(input); if (handled) syncSelectionState(); return handled; } @Override - public boolean charTyped(CharInput input) { + public boolean charTyped(CharacterEvent input) { boolean handled = super.charTyped(input); if (handled) syncSelectionState(); return handled; } @Override - public void onClick(Click click, boolean doubleClick) { + public void onClick(MouseButtonEvent click, boolean doubleClick) { super.onClick(click, doubleClick); syncSelectionState(); } @@ -133,11 +133,11 @@ private boolean insideClip(double mouseX, double mouseY) { && mouseY < clipBottom; } - private void renderTextContent(DrawContext context) { - String text = getText(); - int cursor = clampIndex(getCursor()); + private void renderTextContent(GuiGraphics context) { + String text = getValue(); + int cursor = clampIndex(getCursorPosition()); int safeVisibleStart = clampIndex(visibleStart); - String visibleText = textRenderer.trimToWidth(text.substring(safeVisibleStart), getInnerWidth()); + String visibleText = textRenderer.plainSubstrByWidth(text.substring(safeVisibleStart), getInnerWidth()); int visibleEnd = Math.min(text.length(), safeVisibleStart + visibleText.length()); int innerX = getX(); int innerY = getY(); @@ -151,51 +151,51 @@ private void renderTextContent(DrawContext context) { context.disableScissor(); } - private void renderSelection(DrawContext context, String text, int visibleStart, int visibleEnd, int innerX, int right) { - if (!isFocused() || selectionEnd == getCursor()) { + private void renderSelection(GuiGraphics context, String text, int visibleStart, int visibleEnd, int innerX, int right) { + if (!isFocused() || selectionEnd == getCursorPosition()) { return; } - int start = Math.min(clampIndex(getCursor()), clampIndex(selectionEnd)); - int end = Math.max(clampIndex(getCursor()), clampIndex(selectionEnd)); + 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.getWidth(text.substring(visibleStart, visibleSelectionStart)); - int x2 = innerX + textRenderer.getWidth(text.substring(visibleStart, visibleSelectionEnd)); + 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(DrawContext context, String text, int cursor, int visibleStart, int visibleEnd, int innerX, int innerY, int right, int color) { + private void renderCursor(GuiGraphics 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.getWidth(text.substring(visibleStart, cursor)); + 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(DrawContext context, String text, int x, int y, int color) { + private void drawText(GuiGraphics context, String text, int x, int y, int color) { if (text.isEmpty()) { return; } if (theme.textShadow()) { - context.drawTextWithShadow(textRenderer, text, x, y, color); + context.drawString(textRenderer, text, x, y, color); return; } - context.drawText(textRenderer, text, x, y, color, false); + context.drawString(textRenderer, text, x, y, color, false); } private void syncSelectionState() { syncSelectionStart(); - if (getSelectedText().isEmpty()) { - selectionEnd = getCursor(); + if (getHighlighted().isEmpty()) { + selectionEnd = getCursorPosition(); } else { selectionEnd = inferSelectionEnd(); updateVisibleStart(selectionEnd); @@ -203,17 +203,17 @@ private void syncSelectionState() { } private void syncSelectionStart() { - int cursor = clampIndex(getCursor()); - if (selectionEnd > getText().length()) { + int cursor = clampIndex(getCursorPosition()); + if (selectionEnd > getValue().length()) { selectionEnd = cursor; } updateVisibleStart(cursor); } private int inferSelectionEnd() { - String text = getText(); - String selected = getSelectedText(); - int cursor = clampIndex(getCursor()); + 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; @@ -225,19 +225,19 @@ private int inferSelectionEnd() { } private void updateVisibleStart(int targetIndex) { - String text = getText(); + String text = getValue(); int target = clampIndex(targetIndex); visibleStart = Math.clamp(visibleStart, 0, text.length()); if (target < visibleStart) { visibleStart = target; return; } - while (visibleStart < target && textRenderer.getWidth(text.substring(visibleStart, target)) > getInnerWidth()) { + while (visibleStart < target && textRenderer.width(text.substring(visibleStart, target)) > getInnerWidth()) { visibleStart++; } } private int clampIndex(int index) { - return Math.clamp(index, 0, getText().length()); + return Math.clamp(index, 0, getValue().length()); } } diff --git a/src/client/java/com/github/squi2rel/vp/creation/VpUiRenderer.java b/src/client/java/com/github/squi2rel/vp/creation/VpUiRenderer.java index 849120c..be2daa4 100644 --- a/src/client/java/com/github/squi2rel/vp/creation/VpUiRenderer.java +++ b/src/client/java/com/github/squi2rel/vp/creation/VpUiRenderer.java @@ -1,17 +1,17 @@ package com.github.squi2rel.vp.creation; -import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.GuiGraphics; final class VpUiRenderer { private VpUiRenderer() { } - static void drawBox(DrawContext context, int x, int y, int width, int height, int fillColor, int borderColor) { + static void drawBox(GuiGraphics 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.drawStrokedRectangle(x, y, width, height, borderColor); + context.renderOutline(x, y, width, height, borderColor); } static int blend(int startColor, int endColor, float amount) { diff --git a/src/client/java/com/github/squi2rel/vp/creation/YouTubeAuthScreen.java b/src/client/java/com/github/squi2rel/vp/creation/YouTubeAuthScreen.java index 63c0293..fda62fa 100644 --- a/src/client/java/com/github/squi2rel/vp/creation/YouTubeAuthScreen.java +++ b/src/client/java/com/github/squi2rel/vp/creation/YouTubeAuthScreen.java @@ -2,13 +2,12 @@ import com.github.squi2rel.vp.VideoPlayerClient; import com.github.squi2rel.vp.i18n.VpTexts; -import net.minecraft.client.gui.DrawContext; -import net.minecraft.client.gui.screen.Screen; -import net.minecraft.text.OrderedText; -import net.minecraft.text.Text; -import net.minecraft.util.Formatting; - import java.util.List; +import net.minecraft.ChatFormatting; +import net.minecraft.client.gui.GuiGraphics; +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(); @@ -25,7 +24,7 @@ public final class YouTubeAuthScreen extends Screen { private VpButtonWidget save; private VpButtonWidget clear; private VpButtonWidget close; - private Text status = Text.empty(); + private Component status = Component.empty(); public YouTubeAuthScreen(Screen parent) { super(VpTexts.tr("screen.videoplayer.youtube_auth", "YouTube Authentication")); @@ -36,44 +35,44 @@ public YouTubeAuthScreen(Screen parent) { protected void init() { Layout layout = layout(); int fieldWidth = layout.panelWidth - 48; - cookiesFile = new VpTextFieldWidget(textRenderer, layout.left + 24, layout.top + 42, fieldWidth, CONTROL_HEIGHT, + 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.setText(currentCookiesFile()); - browserSpec = new VpTextFieldWidget(textRenderer, layout.left + 24, layout.top + 80, fieldWidth, CONTROL_HEIGHT, + 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.setText(currentBrowserSpec()); + 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 -> close(), THEME); - addDrawableChild(cookiesFile); - addDrawableChild(browserSpec); - addDrawableChild(save); - addDrawableChild(clear); - addDrawableChild(close); + VpTexts.tr("button.videoplayer.close", "Close"), ignored -> onClose(), THEME); + addRenderableWidget(cookiesFile); + addRenderableWidget(browserSpec); + addRenderableWidget(save); + addRenderableWidget(clear); + addRenderableWidget(close); } @Override - public void close() { - if (client != null) client.setScreen(parent); + public void onClose() { + if (minecraft != null) minecraft.setScreen(parent); } @Override - public boolean shouldPause() { + public boolean isPauseScreen() { return false; } @Override - public void render(DrawContext context, int mouseX, int mouseY, float delta) { + public void render(GuiGraphics 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.drawStrokedRectangle(layout.left, layout.top, layout.panelWidth, layout.panelHeight, THEME.panelBorderColor()); - context.drawCenteredTextWithShadow(textRenderer, title, width / 2, layout.top + 8, THEME.primaryTextColor()); + context.renderOutline(layout.left, layout.top, layout.panelWidth, layout.panelHeight, THEME.panelBorderColor()); + context.drawCenteredString(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; @@ -87,18 +86,18 @@ public void render(DrawContext context, int mouseX, int mouseY, float delta) { private void saveValues() { if (VideoPlayerClient.config == null) return; - VideoPlayerClient.config.youtubeCookiesFile = cookiesFile.getText().trim(); - VideoPlayerClient.config.youtubeCookiesFromBrowser = browserSpec.getText().trim(); + 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").formatted(Formatting.GREEN); + status = VpTexts.tr("message.videoplayer.youtube_auth_saved", "YouTube authentication settings saved").withStyle(ChatFormatting.GREEN); } private void clearValues() { - cookiesFile.setText(""); - browserSpec.setText(""); + cookiesFile.setValue(""); + browserSpec.setValue(""); saveValues(); - status = VpTexts.tr("message.videoplayer.youtube_auth_cleared", "YouTube authentication settings cleared").formatted(Formatting.GREEN); + status = VpTexts.tr("message.videoplayer.youtube_auth_cleared", "YouTube authentication settings cleared").withStyle(ChatFormatting.GREEN); } private String currentCookiesFile() { @@ -114,11 +113,11 @@ private String currentBrowserSpec() { private Layout layout() { int panelWidth = Math.min(PANEL_WIDTH, Math.max(260, width - 24)); int contentWidth = panelWidth - 48; - List fileHintLines = textRenderer.wrapLines(VpTexts.tr( + 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 = textRenderer.wrapLines(VpTexts.tr( + 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); @@ -141,26 +140,26 @@ private Layout layout() { fileHintLines.subList(0, fileLines), serverHintLines.subList(0, serverLines)); } - private int drawWrappedLabel(DrawContext context, List lines, int x, int y) { + private int drawWrappedLabel(GuiGraphics context, List lines, int x, int y) { int currentY = y; - for (OrderedText line : lines) { - context.drawTextWithShadow(textRenderer, line, x, currentY, THEME.secondaryTextColor()); + for (FormattedCharSequence line : lines) { + context.drawString(font, line, x, currentY, THEME.secondaryTextColor()); currentY += HINT_LINE_HEIGHT; } return currentY; } - private void drawTrimmedLabel(DrawContext context, Text text, int x, int y, int maxWidth) { - Text visible = Text.literal(textRenderer.trimToWidth(text, Math.max(1, maxWidth)).getString()); + private void drawTrimmedLabel(GuiGraphics 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(DrawContext context, Text text, int x, int y) { - context.drawTextWithShadow(textRenderer, text, x, y, THEME.secondaryTextColor()); + private void drawLabel(GuiGraphics context, Component text, int x, int y) { + context.drawString(font, text, x, y, THEME.secondaryTextColor()); } private record Layout(int panelWidth, int panelHeight, int contentWidth, int left, int top, - List fileHintLines, List serverHintLines) { + List fileHintLines, List serverHintLines) { private int buttonY() { return top + panelHeight - 26; } diff --git a/src/client/java/com/github/squi2rel/vp/danmaku/ClientDanmakuController.java b/src/client/java/com/github/squi2rel/vp/danmaku/ClientDanmakuController.java index 9d641d4..6dbe075 100644 --- a/src/client/java/com/github/squi2rel/vp/danmaku/ClientDanmakuController.java +++ b/src/client/java/com/github/squi2rel/vp/danmaku/ClientDanmakuController.java @@ -5,8 +5,6 @@ import com.github.squi2rel.vp.video.ClientVideoScreen; import com.github.squi2rel.vp.video.ScreenMetadata; import com.github.squi2rel.vp.video.ScreenSurface; -import net.minecraft.client.MinecraftClient; - import java.util.ArrayList; import java.util.Comparator; import java.util.HashSet; @@ -17,6 +15,7 @@ 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; @@ -284,13 +283,13 @@ 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 -> MinecraftClient.getInstance().execute(() -> { + 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 -> { - MinecraftClient.getInstance().execute(() -> { + Minecraft.getInstance().execute(() -> { if (!Objects.equals(expectedInfoKey, currentInfoKey)) return; loadingSegments.remove(segment); loadedSegments.add(segment); diff --git a/src/client/java/com/github/squi2rel/vp/danmaku/ClientDanmakuRenderer.java b/src/client/java/com/github/squi2rel/vp/danmaku/ClientDanmakuRenderer.java index 95f3dfc..dc173cb 100644 --- a/src/client/java/com/github/squi2rel/vp/danmaku/ClientDanmakuRenderer.java +++ b/src/client/java/com/github/squi2rel/vp/danmaku/ClientDanmakuRenderer.java @@ -7,21 +7,12 @@ import com.mojang.blaze3d.systems.RenderSystem; import com.mojang.blaze3d.textures.FilterMode; import com.mojang.blaze3d.textures.GpuTextureView; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; import com.github.squi2rel.vp.video.ClientVideoScreen; import com.github.squi2rel.vp.video.ScreenGeometry; import com.github.squi2rel.vp.video.ScreenMetadata; import com.github.squi2rel.vp.video.ScreenSurface; -import net.minecraft.client.font.TextDrawable; -import net.minecraft.client.font.TextRenderer; -import net.minecraft.client.gui.DrawContext; -import net.minecraft.client.gui.ScreenRect; -import net.minecraft.client.gui.render.state.SimpleGuiElementRenderState; -import net.minecraft.client.render.RenderLayer; -import net.minecraft.client.render.VertexConsumer; -import net.minecraft.client.render.VertexConsumerProvider; -import net.minecraft.client.texture.TextureSetup; -import net.minecraft.client.util.math.MatrixStack; -import net.minecraft.util.Identifier; import org.joml.Matrix3x2f; import org.joml.Matrix4f; import org.joml.Vector2f; @@ -33,6 +24,15 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.font.TextRenderable; +import net.minecraft.client.gui.navigation.ScreenRectangle; +import net.minecraft.client.gui.render.TextureSetup; +import net.minecraft.client.gui.render.state.GuiElementRenderState; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.rendertype.RenderType; +import net.minecraft.resources.Identifier; public final class ClientDanmakuRenderer { private static final float BASE_ROLLING_SURFACE_GAP = 0.003f; @@ -47,7 +47,7 @@ public final class ClientDanmakuRenderer { private static final float SUBTITLE_BACKGROUND_PADDING_X = 4.0f; private static final float SUBTITLE_BACKGROUND_PADDING_Y = 2.0f; private static final float SUBTITLE_BACKGROUND_SURFACE_GAP = 0.00035f; - private static final Identifier SUBTITLE_BACKGROUND_TEXTURE = Identifier.of("minecraft", "textures/block/white_concrete.png"); + private static final Identifier SUBTITLE_BACKGROUND_TEXTURE = Identifier.fromNamespaceAndPath("minecraft", "textures/block/white_concrete.png"); private ClientDanmakuRenderer() { } @@ -70,7 +70,7 @@ public static void clearCache() { BiliBiliSourceRegistry.clear(); } - public static void draw(MatrixStack matrices, VertexConsumerProvider consumers, ClientVideoScreen target) { + public static void draw(PoseStack matrices, MultiBufferSource consumers, ClientVideoScreen target) { if (!ClientDanmakuController.isEnabledOn(target) || target.surface == ScreenSurface.SPHERE_360) return; ClientVideoScreen playback = target.getScreen(); if (playback == null) return; @@ -83,7 +83,7 @@ public static void draw(MatrixStack matrices, VertexConsumerProvider consumers, drawDanmakuItems(consumers, context, target, items); } - public static void drawSubtitles(MatrixStack matrices, VertexConsumerProvider consumers, ClientVideoScreen target) { + public static void drawSubtitles(PoseStack matrices, MultiBufferSource consumers, ClientVideoScreen target) { if (target == null || target.surface == ScreenSurface.SPHERE_360) return; ClientVideoScreen playback = target.getScreen(); if (playback == null) return; @@ -96,7 +96,7 @@ public static void drawSubtitles(MatrixStack matrices, VertexConsumerProvider co drawSubtitleItems(consumers, context, target, items); } - public static void drawPreview(DrawContext context, ClientVideoScreen target, int x, int y, int width, int height) { + public static void drawPreview(GuiGraphics context, ClientVideoScreen target, int x, int y, int width, int height) { if (target == null || width <= 0 || height <= 0) return; if (!ClientDanmakuController.isEnabledOn(target) || target.surface == ScreenSurface.SPHERE_360) return; ClientVideoScreen playback = target.getScreen(); @@ -112,7 +112,7 @@ public static void drawPreview(DrawContext context, ClientVideoScreen target, in DanmakuTextLayoutCache.prepare(items); context.enableScissor(x, y, x + width, y + height); try { - GuiTextBatch batch = new GuiTextBatch(context.scissorStack.peekLast()); + GuiTextBatch batch = new GuiTextBatch(context.scissorStack.peek()); for (ClientDanmakuController.RenderableDanmaku item : items) { collectPreviewItem(context, batch, item, x, y, width, height, scaleX, scaleY, alpha); } @@ -122,7 +122,7 @@ public static void drawPreview(DrawContext context, ClientVideoScreen target, in } } - public static void drawSubtitlePreview(DrawContext context, ClientVideoScreen target, int x, int y, int width, int height) { + public static void drawSubtitlePreview(GuiGraphics context, ClientVideoScreen target, int x, int y, int width, int height) { if (target == null || width <= 0 || height <= 0 || target.surface == ScreenSurface.SPHERE_360) return; ClientVideoScreen playback = target.getScreen(); if (playback == null) return; @@ -136,7 +136,7 @@ public static void drawSubtitlePreview(DrawContext context, ClientVideoScreen ta DanmakuTextLayoutCache.prepare(items); context.enableScissor(x, y, x + width, y + height); try { - GuiTextBatch batch = new GuiTextBatch(context.scissorStack.peekLast()); + GuiTextBatch batch = new GuiTextBatch(context.scissorStack.peek()); for (ClientDanmakuController.RenderableDanmaku item : items) { drawPreviewSubtitleBackground(context, item, x, y, width, height, scaleX, scaleY); collectPreviewItem(context, batch, item, x, y, width, height, scaleX, scaleY, alpha(SUBTITLE_VERTEX_COLOR)); @@ -198,7 +198,7 @@ private static RenderContext renderContext(ClientVideoScreen target, ClientVideo return new RenderContext(targetGeometry, projection, source, targetBounds, rootTarget, directPlane, renderOrigin); } - private static void collectPreviewItem(DrawContext context, GuiTextBatch batch, + private static void collectPreviewItem(GuiGraphics context, GuiTextBatch batch, ClientDanmakuController.RenderableDanmaku item, int x, int y, int width, int height, float scaleX, float scaleY, int alpha) { @@ -212,16 +212,16 @@ private static void collectPreviewItem(DrawContext context, GuiTextBatch batch, return; } - Matrix3x2f pose = new Matrix3x2f(context.getMatrices()) + Matrix3x2f pose = new Matrix3x2f(context.pose()) .translate(drawX, drawY) .scale(item.scale() * scaleX, item.scale() * scaleY); Matrix4f matrix = new Matrix4f().mul(pose); DanmakuTextLayoutCache.CachedLayout layout = DanmakuTextLayoutCache.get(item.text()); int bodyColor = colorWithAlpha(item.color(), alpha); - layout.body().draw(new GuiGlyphCollector(batch, matrix, bodyColor)); + layout.body().visit(new GuiGlyphCollector(batch, matrix, bodyColor)); } - private static void drawPreviewSubtitleBackground(DrawContext context, ClientDanmakuController.RenderableDanmaku item, + private static void drawPreviewSubtitleBackground(GuiGraphics context, ClientDanmakuController.RenderableDanmaku item, int x, int y, int width, int height, float scaleX, float scaleY) { float padX = SUBTITLE_BACKGROUND_PADDING_X * item.scale() * scaleX; float padY = SUBTITLE_BACKGROUND_PADDING_Y * item.scale() * scaleY; @@ -234,7 +234,7 @@ private static void drawPreviewSubtitleBackground(DrawContext context, ClientDan } } - private static void drawDanmakuItems(VertexConsumerProvider consumers, RenderContext context, ClientVideoScreen target, + private static void drawDanmakuItems(MultiBufferSource consumers, RenderContext context, ClientVideoScreen target, List items) { WorldTextBatch batch = new WorldTextBatch(); int rollingCount = countItems(items, false); @@ -255,7 +255,7 @@ private static void drawDanmakuItems(VertexConsumerProvider consumers, RenderCon batch.submit(consumers); } - private static void drawSubtitleItems(VertexConsumerProvider consumers, RenderContext context, ClientVideoScreen target, + private static void drawSubtitleItems(MultiBufferSource consumers, RenderContext context, ClientVideoScreen target, List items) { drawSubtitleBackgrounds(consumers, context, target, items); WorldTextBatch batch = new WorldTextBatch(); @@ -267,7 +267,7 @@ private static void drawSubtitleItems(VertexConsumerProvider consumers, RenderCo batch.submit(consumers); } - private static void drawSubtitleBackgrounds(VertexConsumerProvider consumers, RenderContext context, ClientVideoScreen target, + private static void drawSubtitleBackgrounds(MultiBufferSource consumers, RenderContext context, ClientVideoScreen target, List items) { VertexConsumer consumer = consumers.getBuffer(ScreenRenderer.getTranslucentLayer(SUBTITLE_BACKGROUND_TEXTURE)); int count = items.size(); @@ -321,8 +321,8 @@ private static void drawDanmakuItem(WorldTextBatch batch, RenderContext context, Matrix4f matrix = new Matrix4f().translation(item.x(), item.y(), 0.0f).scale(item.scale(), item.scale(), 1.0f); int alpha = alpha(vertexColor); int bodyColor = colorWithAlpha(item.color(), alpha); - layout.body().draw(new MappedGlyphDrawer(batch, context, target, normalOffset, matrix, - TextRenderer.TextLayerType.POLYGON_OFFSET, bodyColor)); + layout.body().visit(new MappedGlyphDrawer(batch, context, target, normalOffset, matrix, + Font.DisplayMode.POLYGON_OFFSET, bodyColor)); } private static int opacityVertexColor() { @@ -595,9 +595,9 @@ private static void drawBackgroundVertex(SurfaceTriangle triangle, Vector3f rend Vector3f vertex = triangle.interpolate(point.x, point.y) .add(normalOffset) .add(renderOrigin); - consumer.vertex(vertex.x, vertex.y, vertex.z) - .color(vertexColor) - .texture(point.u, point.v); + consumer.addVertex(vertex.x, vertex.y, vertex.z) + .setColor(vertexColor) + .setUv(point.u, point.v); } private static void drawBackgroundPlaneVertex(DirectPlane plane, Vector3f renderOrigin, Vector3f normalOffset, VertexConsumer consumer, @@ -608,9 +608,9 @@ private static void drawBackgroundPlaneVertex(DirectPlane plane, Vector3f render + normalOffset.y + renderOrigin.y; float z = plane.origin.z + plane.xAxis.z * (point.x - plane.minX) + plane.yAxis.z * (point.y - plane.minY) + normalOffset.z + renderOrigin.z; - consumer.vertex(x, y, z) - .color(vertexColor) - .texture(point.u, point.v); + consumer.addVertex(x, y, z) + .setColor(vertexColor) + .setUv(point.u, point.v); } private static void drawTriangle(SurfaceTriangle triangle, Vector3f renderOrigin, Vector3f normalOffset, VertexConsumer consumer, @@ -630,10 +630,10 @@ private static void drawVertex(SurfaceTriangle triangle, Vector3f renderOrigin, Vector3f vertex = triangle.interpolate(point.x, point.y) .add(normalOffset) .add(renderOrigin); - consumer.vertex(vertex.x, vertex.y, vertex.z) - .color(vertexColor) - .texture(point.u, point.v) - .light(light); + consumer.addVertex(vertex.x, vertex.y, vertex.z) + .setColor(vertexColor) + .setUv(point.u, point.v) + .setLight(light); } private static void drawPlaneVertex(DirectPlane plane, Vector3f renderOrigin, Vector3f normalOffset, VertexConsumer consumer, @@ -644,10 +644,10 @@ private static void drawPlaneVertex(DirectPlane plane, Vector3f renderOrigin, Ve + normalOffset.y + renderOrigin.y; float z = plane.origin.z + plane.xAxis.z * (point.x - plane.minX) + plane.yAxis.z * (point.y - plane.minY) + normalOffset.z + renderOrigin.z; - consumer.vertex(x, y, z) - .color(vertexColor) - .texture(point.u, point.v) - .light(light); + consumer.addVertex(x, y, z) + .setColor(vertexColor) + .setUv(point.u, point.v) + .setLight(light); } private static float signedArea(List polygon) { @@ -738,24 +738,24 @@ private enum SurfaceCoordinates { } private static final class GuiTextBatch { - private final ScreenRect scissorArea; + private final ScreenRectangle scissorArea; private final Map> verticesByBatch = new HashMap<>(); - private GuiTextBatch(ScreenRect scissorArea) { + private GuiTextBatch(ScreenRectangle scissorArea) { this.scissorArea = scissorArea; } - private void add(TextDrawable drawable, Matrix4f matrix, int color) { - GuiTextBatchKey key = new GuiTextBatchKey(drawable.getPipeline(), drawable.textureView()); + private void add(TextRenderable drawable, Matrix4f matrix, int color) { + GuiTextBatchKey key = new GuiTextBatchKey(drawable.guiPipeline(), drawable.textureView()); ArrayList vertices = verticesByBatch.computeIfAbsent(key, ignored -> new ArrayList<>()); drawable.render(matrix, new GuiGlyphVertexCollector(vertices, color), LIGHT, true); } - private void submit(DrawContext context) { + private void submit(GuiGraphics context) { if (verticesByBatch.isEmpty() || scissorArea == null) return; for (Map.Entry> entry : verticesByBatch.entrySet()) { if (entry.getValue().isEmpty()) continue; - ((DrawContextAccessor) context).videoplayer$getState().addSimpleElement(new GuiTextBatchRenderState( + ((DrawContextAccessor) context).videoplayer$getState().submitGuiElement(new GuiTextBatchRenderState( entry.getKey().pipeline(), entry.getKey().textureView(), List.copyOf(entry.getValue()), @@ -765,7 +765,7 @@ private void submit(DrawContext context) { } } - private static final class GuiGlyphCollector implements TextRenderer.GlyphDrawer { + private static final class GuiGlyphCollector implements Font.GlyphVisitor { private final GuiTextBatch batch; private final Matrix4f matrix; private final int color; @@ -777,12 +777,12 @@ private GuiGlyphCollector(GuiTextBatch batch, Matrix4f matrix, int color) { } @Override - public void drawGlyph(TextDrawable.DrawnGlyphRect glyph) { + public void acceptGlyph(TextRenderable.Styled glyph) { batch.add(glyph, matrix, color); } @Override - public void drawRectangle(TextDrawable rectangle) { + public void acceptEffect(TextRenderable rectangle) { batch.add(rectangle, matrix, color); } } @@ -802,7 +802,7 @@ private GuiGlyphVertexCollector(List vertices, int color) { } @Override - public VertexConsumer vertex(float x, float y, float z) { + public VertexConsumer addVertex(float x, float y, float z) { this.x = x; this.y = y; this.z = z; @@ -810,64 +810,64 @@ public VertexConsumer vertex(float x, float y, float z) { } @Override - public VertexConsumer color(int red, int green, int blue, int alpha) { + public VertexConsumer setColor(int red, int green, int blue, int alpha) { return this; } @Override - public VertexConsumer color(int color) { + public VertexConsumer setColor(int color) { return this; } @Override - public VertexConsumer texture(float u, float v) { + public VertexConsumer setUv(float u, float v) { this.u = u; this.v = v; return this; } @Override - public VertexConsumer overlay(int u, int v) { + public VertexConsumer setUv1(int u, int v) { return this; } @Override - public VertexConsumer light(int u, int v) { + public VertexConsumer setUv2(int u, int v) { vertices.add(new GuiGlyphVertex(x, y, z, this.u, this.v, color, (v << 16) | (u & 0xFFFF))); return this; } @Override - public VertexConsumer normal(float x, float y, float z) { + public VertexConsumer setNormal(float x, float y, float z) { return this; } @Override - public VertexConsumer lineWidth(float width) { + public VertexConsumer setLineWidth(float width) { return this; } } private record GuiTextBatchRenderState(RenderPipeline pipeline, GpuTextureView textureView, List vertices, - ScreenRect bounds) implements SimpleGuiElementRenderState { + ScreenRectangle bounds) implements GuiElementRenderState { @Override - public void setupVertices(VertexConsumer consumer) { + public void buildVertices(VertexConsumer consumer) { for (GuiGlyphVertex vertex : vertices) { - consumer.vertex(vertex.x(), vertex.y(), vertex.z()) - .color(vertex.color()) - .texture(vertex.u(), vertex.v()) - .light(vertex.light()); + consumer.addVertex(vertex.x(), vertex.y(), vertex.z()) + .setColor(vertex.color()) + .setUv(vertex.u(), vertex.v()) + .setLight(vertex.light()); } } @Override public TextureSetup textureSetup() { - return TextureSetup.withLightmap(textureView, RenderSystem.getSamplerCache().get(FilterMode.NEAREST)); + return TextureSetup.singleTextureWithLightmap(textureView, RenderSystem.getSamplerCache().getClampToEdge(FilterMode.NEAREST)); } @Override - public ScreenRect scissorArea() { + public ScreenRectangle scissorArea() { return bounds; } } @@ -879,22 +879,22 @@ private record GuiGlyphVertex(float x, float y, float z, float u, float v, int c } private static final class WorldTextBatch { - private final Map consumers = new LinkedHashMap<>(); + private final Map consumers = new LinkedHashMap<>(); - private VertexConsumer consumer(RenderLayer layer) { + private VertexConsumer consumer(RenderType layer) { return consumers.computeIfAbsent(layer, ignored -> new WorldGlyphVertexCollector()); } - private void submit(VertexConsumerProvider output) { - for (Map.Entry entry : consumers.entrySet()) { + private void submit(MultiBufferSource output) { + for (Map.Entry entry : consumers.entrySet()) { List vertices = entry.getValue().vertices(); if (vertices.isEmpty()) continue; VertexConsumer consumer = output.getBuffer(entry.getKey()); for (WorldGlyphVertex vertex : vertices) { - consumer.vertex(vertex.x(), vertex.y(), vertex.z()) - .color(vertex.color()) - .texture(vertex.u(), vertex.v()) - .light(vertex.light()); + consumer.addVertex(vertex.x(), vertex.y(), vertex.z()) + .setColor(vertex.color()) + .setUv(vertex.u(), vertex.v()) + .setLight(vertex.light()); } } } @@ -914,7 +914,7 @@ private List vertices() { } @Override - public VertexConsumer vertex(float x, float y, float z) { + public VertexConsumer addVertex(float x, float y, float z) { this.x = x; this.y = y; this.z = z; @@ -922,7 +922,7 @@ public VertexConsumer vertex(float x, float y, float z) { } @Override - public VertexConsumer color(int red, int green, int blue, int alpha) { + public VertexConsumer setColor(int red, int green, int blue, int alpha) { this.color = (Math.clamp(alpha, 0, 255) << 24) | (Math.clamp(red, 0, 255) << 16) | (Math.clamp(green, 0, 255) << 8) @@ -931,36 +931,36 @@ public VertexConsumer color(int red, int green, int blue, int alpha) { } @Override - public VertexConsumer color(int color) { + public VertexConsumer setColor(int color) { this.color = color; return this; } @Override - public VertexConsumer texture(float u, float v) { + public VertexConsumer setUv(float u, float v) { this.u = u; this.v = v; return this; } @Override - public VertexConsumer overlay(int u, int v) { + public VertexConsumer setUv1(int u, int v) { return this; } @Override - public VertexConsumer light(int u, int v) { + public VertexConsumer setUv2(int u, int v) { vertices.add(new WorldGlyphVertex(x, y, z, this.u, this.v, color, (v << 16) | (u & 0xFFFF))); return this; } @Override - public VertexConsumer normal(float x, float y, float z) { + public VertexConsumer setNormal(float x, float y, float z) { return this; } @Override - public VertexConsumer lineWidth(float width) { + public VertexConsumer setLineWidth(float width) { return this; } } @@ -974,19 +974,19 @@ private record ClipVertex(float x, float y, float u, float v) { private record GlyphVertex(float x, float y, float u, float v, int light) { } - private static final class MappedGlyphDrawer implements TextRenderer.GlyphDrawer { + private static final class MappedGlyphDrawer implements Font.GlyphVisitor { private final WorldTextBatch batch; private final RenderContext context; private final ClientVideoScreen target; private final Vector3f normalOffset; private final Matrix4f matrix; - private final TextRenderer.TextLayerType layerType; + private final Font.DisplayMode layerType; private final int color; - private final Map layerConsumers = new HashMap<>(); + private final Map layerConsumers = new HashMap<>(); private MappedGlyphDrawer(WorldTextBatch batch, RenderContext context, ClientVideoScreen target, Vector3f normalOffset, Matrix4f matrix, - TextRenderer.TextLayerType layerType, int color) { + Font.DisplayMode layerType, int color) { this.batch = batch; this.context = context; this.target = target; @@ -997,17 +997,17 @@ private MappedGlyphDrawer(WorldTextBatch batch, RenderContext context, ClientVid } @Override - public void drawGlyph(TextDrawable.DrawnGlyphRect glyph) { + public void acceptGlyph(TextRenderable.Styled glyph) { draw(glyph); } @Override - public void drawRectangle(TextDrawable rectangle) { + public void acceptEffect(TextRenderable rectangle) { draw(rectangle); } - private void draw(TextDrawable drawable) { - RenderLayer layer = drawable.getRenderLayer(layerType); + private void draw(TextRenderable drawable) { + RenderType layer = drawable.renderType(layerType); MappingVertexConsumer consumer = layerConsumers.computeIfAbsent(layer, key -> new MappingVertexConsumer(batch.consumer(key), context, target, normalOffset, color)); drawable.render(matrix, consumer, LIGHT, false); @@ -1038,36 +1038,36 @@ private MappingVertexConsumer(VertexConsumer delegate, RenderContext context, Cl } @Override - public VertexConsumer vertex(float x, float y, float z) { + public VertexConsumer addVertex(float x, float y, float z) { this.x = x; this.y = y; return this; } @Override - public VertexConsumer color(int red, int green, int blue, int alpha) { + public VertexConsumer setColor(int red, int green, int blue, int alpha) { return this; } @Override - public VertexConsumer color(int color) { + public VertexConsumer setColor(int color) { return this; } @Override - public VertexConsumer texture(float u, float v) { + public VertexConsumer setUv(float u, float v) { this.u = u; this.v = v; return this; } @Override - public VertexConsumer overlay(int u, int v) { + public VertexConsumer setUv1(int u, int v) { return this; } @Override - public VertexConsumer light(int u, int v) { + public VertexConsumer setUv2(int u, int v) { this.light = (v << 16) | (u & 0xFFFF); vertices[vertexCount++] = new GlyphVertex(x, y, this.u, this.v, light); if (vertexCount == vertices.length) { @@ -1078,12 +1078,12 @@ public VertexConsumer light(int u, int v) { } @Override - public VertexConsumer normal(float x, float y, float z) { + public VertexConsumer setNormal(float x, float y, float z) { return this; } @Override - public VertexConsumer lineWidth(float width) { + public VertexConsumer setLineWidth(float width) { return this; } diff --git a/src/client/java/com/github/squi2rel/vp/danmaku/DanmakuTextLayoutCache.java b/src/client/java/com/github/squi2rel/vp/danmaku/DanmakuTextLayoutCache.java index a7749bc..d71837d 100644 --- a/src/client/java/com/github/squi2rel/vp/danmaku/DanmakuTextLayoutCache.java +++ b/src/client/java/com/github/squi2rel/vp/danmaku/DanmakuTextLayoutCache.java @@ -1,15 +1,14 @@ package com.github.squi2rel.vp.danmaku; -import net.minecraft.client.MinecraftClient; -import net.minecraft.client.font.TextRenderer; -import net.minecraft.text.OrderedText; -import net.minecraft.text.Text; - import java.util.ArrayList; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.network.chat.Component; +import net.minecraft.util.FormattedCharSequence; final class DanmakuTextLayoutCache { private static final int MAX_ENTRIES = 2048; @@ -20,17 +19,17 @@ private DanmakuTextLayoutCache() { } static float measureWidth(String text, float scale) { - TextRenderer textRenderer = MinecraftClient.getInstance().textRenderer; - return Math.max(1.0f, textRenderer.getWidth(safeText(text)) * Math.max(0.01f, scale)); + Font textRenderer = Minecraft.getInstance().font; + return Math.max(1.0f, textRenderer.width(safeText(text)) * Math.max(0.01f, scale)); } static float measureHeight(float scale) { - TextRenderer textRenderer = MinecraftClient.getInstance().textRenderer; - return Math.max(1.0f, textRenderer.fontHeight * Math.max(0.01f, scale)); + Font textRenderer = Minecraft.getInstance().font; + return Math.max(1.0f, textRenderer.lineHeight * Math.max(0.01f, scale)); } - static OrderedText orderedText(String text) { - return Text.literal(safeText(text)).asOrderedText(); + static FormattedCharSequence orderedText(String text) { + return Component.literal(safeText(text)).getVisualOrderText(); } static void prepare(List items) { @@ -45,20 +44,20 @@ static CachedLayout get(String text) { CachedLayout cached = CACHE.get(safe); if (cached != null) return cached; - TextRenderer textRenderer = MinecraftClient.getInstance().textRenderer; - OrderedText ordered = orderedText(safe); - ArrayList outlines = new ArrayList<>(8); + Font textRenderer = Minecraft.getInstance().font; + FormattedCharSequence ordered = orderedText(safe); + ArrayList outlines = new ArrayList<>(8); for (int ox = -1; ox <= 1; ox++) { for (int oy = -1; oy <= 1; oy++) { if (ox == 0 && oy == 0) continue; - outlines.add(textRenderer.prepare(ordered, ox, oy, WHITE, false, true, 0)); + outlines.add(textRenderer.prepareText(ordered, ox, oy, WHITE, false, true, 0)); } } CachedLayout created = new CachedLayout( List.copyOf(outlines), - textRenderer.prepare(ordered, 0, 0, WHITE, false, true, 0), - textRenderer.getWidth(ordered), - textRenderer.fontHeight + textRenderer.prepareText(ordered, 0, 0, WHITE, false, true, 0), + textRenderer.width(ordered), + textRenderer.lineHeight ); CACHE.put(safe, created); evictOverflow(); @@ -81,8 +80,8 @@ private static String safeText(String text) { return text == null ? "" : text; } - record CachedLayout(List outlines, - TextRenderer.GlyphDrawable body, + record CachedLayout(List outlines, + Font.PreparedText body, int width, int height) { } diff --git a/src/client/java/com/github/squi2rel/vp/i18n/VpInputTexts.java b/src/client/java/com/github/squi2rel/vp/i18n/VpInputTexts.java index e7d29ee..6e5527f 100644 --- a/src/client/java/com/github/squi2rel/vp/i18n/VpInputTexts.java +++ b/src/client/java/com/github/squi2rel/vp/i18n/VpInputTexts.java @@ -1,17 +1,17 @@ package com.github.squi2rel.vp.i18n; -import net.minecraft.client.util.InputUtil; -import net.minecraft.text.Text; +import com.mojang.blaze3d.platform.InputConstants; +import net.minecraft.network.chat.Component; public final class VpInputTexts { private VpInputTexts() { } - public static Text key(int keyCode) { - return InputUtil.Type.KEYSYM.createFromCode(keyCode).getLocalizedText(); + public static Component key(int keyCode) { + return InputConstants.Type.KEYSYM.getOrCreate(keyCode).getDisplayName(); } - public static Text mouseButton(int button) { - return InputUtil.Type.MOUSE.createFromCode(button).getLocalizedText(); + public static Component mouseButton(int button) { + return InputConstants.Type.MOUSE.getOrCreate(button).getDisplayName(); } } diff --git a/src/client/java/com/github/squi2rel/vp/i18n/VpTexts.java b/src/client/java/com/github/squi2rel/vp/i18n/VpTexts.java index bb28bd2..309c5c2 100644 --- a/src/client/java/com/github/squi2rel/vp/i18n/VpTexts.java +++ b/src/client/java/com/github/squi2rel/vp/i18n/VpTexts.java @@ -1,23 +1,23 @@ package com.github.squi2rel.vp.i18n; -import net.minecraft.text.Text; -import net.minecraft.text.MutableText; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.MutableComponent; public final class VpTexts { private VpTexts() { } - public static MutableText tr(String key, String fallback, Object... args) { + public static MutableComponent tr(String key, String fallback, Object... args) { return text(VpTranslation.of(key, fallback, args)); } - public static MutableText text(VpTranslation translation) { + public static MutableComponent text(VpTranslation translation) { if (translation == null || translation.isEmpty()) { - return Text.empty(); + return Component.empty(); } if (translation.isLiteral()) { - return Text.literal(translation.fallback()); + return Component.literal(translation.fallback()); } - return Text.translatableWithFallback(translation.key(), translation.fallback(), translation.argumentArray()); + return Component.translatableWithFallback(translation.key(), translation.fallback(), translation.argumentArray()); } } diff --git a/src/client/java/com/github/squi2rel/vp/mixin/client/ClientPlayNetworkHandlerMixin.java b/src/client/java/com/github/squi2rel/vp/mixin/client/ClientPlayNetworkHandlerMixin.java index 7ddc2f2..cfefce8 100644 --- a/src/client/java/com/github/squi2rel/vp/mixin/client/ClientPlayNetworkHandlerMixin.java +++ b/src/client/java/com/github/squi2rel/vp/mixin/client/ClientPlayNetworkHandlerMixin.java @@ -1,15 +1,15 @@ package com.github.squi2rel.vp.mixin.client; import com.github.squi2rel.vp.VideoPlayerClient; -import net.minecraft.client.network.ClientPlayNetworkHandler; +import net.minecraft.client.multiplayer.ClientPacketListener; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; -@Mixin(ClientPlayNetworkHandler.class) +@Mixin(ClientPacketListener.class) public class ClientPlayNetworkHandlerMixin { - @Inject(method = "clearWorld", at = @At("HEAD")) + @Inject(method = "clearLevel", at = @At("HEAD")) public void clearWorld(CallbackInfo ci) { VideoPlayerClient.disconnectHandler.run(); } diff --git a/src/client/java/com/github/squi2rel/vp/mixin/client/DrawContextAccessor.java b/src/client/java/com/github/squi2rel/vp/mixin/client/DrawContextAccessor.java index 8a99f65..14355aa 100644 --- a/src/client/java/com/github/squi2rel/vp/mixin/client/DrawContextAccessor.java +++ b/src/client/java/com/github/squi2rel/vp/mixin/client/DrawContextAccessor.java @@ -1,12 +1,12 @@ package com.github.squi2rel.vp.mixin.client; -import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.render.state.GuiRenderState; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.gen.Accessor; -@Mixin(DrawContext.class) +@Mixin(GuiGraphics.class) public interface DrawContextAccessor { - @Accessor("state") + @Accessor("guiRenderState") GuiRenderState videoplayer$getState(); } diff --git a/src/client/java/com/github/squi2rel/vp/mixin/client/GameRendererAccessor.java b/src/client/java/com/github/squi2rel/vp/mixin/client/GameRendererAccessor.java index de15492..e41f0d7 100644 --- a/src/client/java/com/github/squi2rel/vp/mixin/client/GameRendererAccessor.java +++ b/src/client/java/com/github/squi2rel/vp/mixin/client/GameRendererAccessor.java @@ -1,7 +1,7 @@ package com.github.squi2rel.vp.mixin.client; -import net.minecraft.client.render.GameRenderer; -import net.minecraft.client.render.fog.FogRenderer; +import net.minecraft.client.renderer.GameRenderer; +import net.minecraft.client.renderer.fog.FogRenderer; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.gen.Accessor; diff --git a/src/client/java/com/github/squi2rel/vp/mixin/client/GameRendererMixin.java b/src/client/java/com/github/squi2rel/vp/mixin/client/GameRendererMixin.java index d682def..6d86000 100644 --- a/src/client/java/com/github/squi2rel/vp/mixin/client/GameRendererMixin.java +++ b/src/client/java/com/github/squi2rel/vp/mixin/client/GameRendererMixin.java @@ -2,9 +2,9 @@ import com.github.squi2rel.vp.CameraRenderer; import com.github.squi2rel.vp.VideoPlayerClient; -import net.minecraft.client.render.Camera; -import net.minecraft.client.render.GameRenderer; -import net.minecraft.client.render.RenderTickCounter; +import net.minecraft.client.Camera; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.renderer.GameRenderer; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; @@ -18,8 +18,8 @@ public class GameRendererMixin { if (CameraRenderer.rendering) cir.setReturnValue((float) CameraRenderer.fov); } - @Inject(method = "renderWorld", at = @At("RETURN")) - private void videoplayer$postUpdate(RenderTickCounter tickCounter, CallbackInfo ci) { + @Inject(method = "renderLevel", at = @At("RETURN")) + private void videoplayer$postUpdate(DeltaTracker tickCounter, CallbackInfo ci) { if (!CameraRenderer.rendering) VideoPlayerClient.postUpdate(); } } diff --git a/src/client/java/com/github/squi2rel/vp/mixin/client/MinecraftClientAccessor.java b/src/client/java/com/github/squi2rel/vp/mixin/client/MinecraftClientAccessor.java index ba03f99..5fa6725 100644 --- a/src/client/java/com/github/squi2rel/vp/mixin/client/MinecraftClientAccessor.java +++ b/src/client/java/com/github/squi2rel/vp/mixin/client/MinecraftClientAccessor.java @@ -1,17 +1,17 @@ package com.github.squi2rel.vp.mixin.client; -import net.minecraft.client.MinecraftClient; -import net.minecraft.client.gl.Framebuffer; +import com.mojang.blaze3d.pipeline.RenderTarget; +import net.minecraft.client.Minecraft; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Mutable; import org.spongepowered.asm.mixin.gen.Accessor; -@Mixin(MinecraftClient.class) +@Mixin(Minecraft.class) public interface MinecraftClientAccessor { - @Accessor("framebuffer") - Framebuffer videoplayer$getFramebuffer(); + @Accessor("mainRenderTarget") + RenderTarget videoplayer$getFramebuffer(); - @Accessor("framebuffer") + @Accessor("mainRenderTarget") @Mutable - void videoplayer$setFramebuffer(Framebuffer framebuffer); + void videoplayer$setFramebuffer(RenderTarget framebuffer); } diff --git a/src/client/java/com/github/squi2rel/vp/mixin/client/MinecraftClientMixin.java b/src/client/java/com/github/squi2rel/vp/mixin/client/MinecraftClientMixin.java index e7ae2ce..f044299 100644 --- a/src/client/java/com/github/squi2rel/vp/mixin/client/MinecraftClientMixin.java +++ b/src/client/java/com/github/squi2rel/vp/mixin/client/MinecraftClientMixin.java @@ -1,15 +1,15 @@ package com.github.squi2rel.vp.mixin.client; import com.github.squi2rel.vp.VideoPlayerClient; -import net.minecraft.client.MinecraftClient; +import net.minecraft.client.Minecraft; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; -@Mixin(MinecraftClient.class) +@Mixin(Minecraft.class) public class MinecraftClientMixin { - @Inject(method = "render", at = @At("HEAD")) + @Inject(method = "runTick", at = @At("HEAD")) public void render(boolean tick, CallbackInfo ci) { VideoPlayerClient.updated = false; } diff --git a/src/client/java/com/github/squi2rel/vp/mixin/client/WindowMixin.java b/src/client/java/com/github/squi2rel/vp/mixin/client/WindowMixin.java index 25427fc..1ef04ff 100644 --- a/src/client/java/com/github/squi2rel/vp/mixin/client/WindowMixin.java +++ b/src/client/java/com/github/squi2rel/vp/mixin/client/WindowMixin.java @@ -1,7 +1,7 @@ package com.github.squi2rel.vp.mixin.client; import com.github.squi2rel.vp.CameraRenderer; -import net.minecraft.client.util.Window; +import com.mojang.blaze3d.platform.Window; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; @@ -9,12 +9,12 @@ @Mixin(Window.class) public class WindowMixin { - @Inject(method = "getFramebufferWidth", at = @At("HEAD"), cancellable = true) + @Inject(method = "getWidth", at = @At("HEAD"), cancellable = true) private void videoplayer$framebufferWidth(CallbackInfoReturnable cir) { if (CameraRenderer.rendering) cir.setReturnValue(CameraRenderer.width); } - @Inject(method = "getFramebufferHeight", at = @At("HEAD"), cancellable = true) + @Inject(method = "getHeight", at = @At("HEAD"), cancellable = true) private void videoplayer$framebufferHeight(CallbackInfoReturnable cir) { if (CameraRenderer.rendering) cir.setReturnValue(CameraRenderer.height); } diff --git a/src/client/java/com/github/squi2rel/vp/mixin/client/WorldRendererMixin.java b/src/client/java/com/github/squi2rel/vp/mixin/client/WorldRendererMixin.java index d32dbb4..1f5b92c 100644 --- a/src/client/java/com/github/squi2rel/vp/mixin/client/WorldRendererMixin.java +++ b/src/client/java/com/github/squi2rel/vp/mixin/client/WorldRendererMixin.java @@ -1,15 +1,15 @@ package com.github.squi2rel.vp.mixin.client; import com.github.squi2rel.vp.ScreenRenderer; -import net.minecraft.client.render.WorldRenderer; +import net.minecraft.client.renderer.LevelRenderer; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; -@Mixin(WorldRenderer.class) +@Mixin(LevelRenderer.class) public class WorldRendererMixin { - @Inject(method = "renderClouds", at = @At("HEAD"), cancellable = true) + @Inject(method = "addCloudsPass", at = @At("HEAD"), cancellable = true) public void noClouds(CallbackInfo ci) { if (ScreenRenderer.skybox) ci.cancel(); } diff --git a/src/client/java/com/github/squi2rel/vp/video/AbstractCameraPlayer.java b/src/client/java/com/github/squi2rel/vp/video/AbstractCameraPlayer.java index 8840290..f91ac45 100644 --- a/src/client/java/com/github/squi2rel/vp/video/AbstractCameraPlayer.java +++ b/src/client/java/com/github/squi2rel/vp/video/AbstractCameraPlayer.java @@ -1,15 +1,16 @@ package com.github.squi2rel.vp.video; -import net.minecraft.client.MinecraftClient; -import net.minecraft.client.gl.Framebuffer; -import net.minecraft.client.gl.SimpleFramebuffer; -import net.minecraft.client.texture.GlTexture; -import net.minecraft.client.util.Window; +import com.github.squi2rel.vp.ScreenRenderer; +import com.mojang.blaze3d.opengl.GlTexture; +import com.mojang.blaze3d.pipeline.RenderTarget; +import com.mojang.blaze3d.pipeline.TextureTarget; +import com.mojang.blaze3d.platform.Window; +import net.minecraft.client.Minecraft; public abstract class AbstractCameraPlayer extends AbstractScreenPlayer implements MetaListener { - protected Framebuffer framebuffer; - private Framebuffer framebuffer1; - private Framebuffer framebuffer2; + protected RenderTarget framebuffer; + private RenderTarget framebuffer1; + private RenderTarget framebuffer2; private boolean first = true; protected float aspect = 16f / 9f; protected int targetWidth = 16; @@ -22,15 +23,17 @@ protected AbstractCameraPlayer(ClientVideoScreen screen) { @Override public void init() { - framebuffer1 = new SimpleFramebuffer("VideoPlayer camera 1", targetWidth, targetHeight, true); - framebuffer2 = new SimpleFramebuffer("VideoPlayer camera 2", targetWidth, targetHeight, true); + framebuffer1 = new TextureTarget("VideoPlayer camera 1", targetWidth, targetHeight, true); + framebuffer2 = new TextureTarget("VideoPlayer camera 2", targetWidth, targetHeight, true); framebuffer = framebuffer1; } @Override public void cleanup() { - if (framebuffer1 != null) framebuffer1.delete(); - if (framebuffer2 != null) framebuffer2.delete(); + releaseFramebuffer(framebuffer1); + releaseFramebuffer(framebuffer2); + if (framebuffer1 != null) framebuffer1.destroyBuffers(); + if (framebuffer2 != null) framebuffer2.destroyBuffers(); framebuffer1 = null; framebuffer2 = null; framebuffer = null; @@ -45,16 +48,19 @@ public void swapTexture() { @Override public void updateTexture() { - Window window = MinecraftClient.getInstance().getWindow(); - int width = Math.max(1, window.getFramebufferWidth()); + Window window = Minecraft.getInstance().getWindow(); + int width = Math.max(1, window.getWidth()); int height = Math.max(1, Math.round(width / aspect)); - if (height > window.getFramebufferHeight()) { - height = Math.max(1, window.getFramebufferHeight()); + if (height > window.getHeight()) { + height = Math.max(1, window.getHeight()); width = Math.max(1, Math.round(height * aspect)); } targetWidth = width; targetHeight = height; - if (framebuffer != null && (framebuffer.textureWidth != width || framebuffer.textureHeight != height)) framebuffer.resize(width, height); + if (framebuffer != null && (framebuffer.width != width || framebuffer.height != height)) { + releaseFramebuffer(framebuffer); + framebuffer.resize(width, height); + } } @Override @@ -65,7 +71,7 @@ public void onMetaChanged() { @Override public int getTextureId() { - return framebuffer != null && framebuffer.getColorAttachment() instanceof GlTexture texture ? texture.getGlId() : -1; + return framebuffer != null && framebuffer.getColorTexture() instanceof GlTexture texture ? texture.glId() : -1; } @Override @@ -92,4 +98,10 @@ public boolean flippedY() { public boolean isPostUpdate() { return true; } + + private static void releaseFramebuffer(RenderTarget target) { + if (target != null && target.getColorTexture() instanceof GlTexture texture) { + ScreenRenderer.releaseTexture(texture.glId()); + } + } } diff --git a/src/client/java/com/github/squi2rel/vp/video/ClientVideoScreen.java b/src/client/java/com/github/squi2rel/vp/video/ClientVideoScreen.java index e4ccfa0..ce6f2a6 100644 --- a/src/client/java/com/github/squi2rel/vp/video/ClientVideoScreen.java +++ b/src/client/java/com/github/squi2rel/vp/video/ClientVideoScreen.java @@ -8,15 +8,15 @@ import com.github.squi2rel.vp.danmaku.ClientDanmakuRenderer; import com.github.squi2rel.vp.danmaku.ClientSubtitleController; import com.github.squi2rel.vp.provider.VideoInfo; -import net.minecraft.client.MinecraftClient; -import net.minecraft.client.render.VertexConsumerProvider; -import net.minecraft.client.util.math.MatrixStack; -import net.minecraft.text.Text; -import net.minecraft.util.Formatting; +import com.mojang.blaze3d.vertex.PoseStack; import org.joml.Vector3f; import java.util.*; import java.util.concurrent.CompletableFuture; +import net.minecraft.ChatFormatting; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.network.chat.Component; public class ClientVideoScreen extends VideoScreen { public IVideoPlayer player = null; @@ -147,7 +147,7 @@ public void cleanup() { if (old != null) old.cleanup(); } - public void draw(MatrixStack matrices, VertexConsumerProvider consumers) { + public void draw(PoseStack matrices, MultiBufferSource consumers) { if (shouldDrawPlaceholder()) { boolean showIdleImage = metadata == null || metadata.getBool(ScreenMetadata.KEY_SHOW_IDLE_IMAGE, true); if (!shouldKeepFallbackFrame(hasDisplayPlaybackContent(), showIdleImage)) return; @@ -456,11 +456,11 @@ public void autoSync(long roundTrip, long syncProgress) { if (corrected) setProgress(syncProgress); if (metadata.getBool("debug", false)) { - MinecraftClient.getInstance().inGameHud.setOverlayMessage(Text.literal( + Minecraft.getInstance().gui.setOverlayMessage(Component.literal( "local: %s, server: %s, rtt: %s, delta: %s, corrected: %s, rate: %.2f".formatted( progress, syncProgress, rtt, delta, corrected, ratePlayer.getRate() ) - ).formatted(Formatting.GREEN), false); + ).withStyle(ChatFormatting.GREEN), false); } } } diff --git a/src/client/java/com/github/squi2rel/vp/video/ClonePlayer.java b/src/client/java/com/github/squi2rel/vp/video/ClonePlayer.java index cecb2a8..2d86f80 100644 --- a/src/client/java/com/github/squi2rel/vp/video/ClonePlayer.java +++ b/src/client/java/com/github/squi2rel/vp/video/ClonePlayer.java @@ -1,6 +1,6 @@ package com.github.squi2rel.vp.video; -import net.minecraft.client.render.VertexConsumer; +import com.mojang.blaze3d.vertex.VertexConsumer; import org.jetbrains.annotations.Nullable; import org.joml.Matrix4f; import org.joml.Vector2f; diff --git a/src/client/java/com/github/squi2rel/vp/video/Degree360Player.java b/src/client/java/com/github/squi2rel/vp/video/Degree360Player.java index 3ebb069..782d77b 100644 --- a/src/client/java/com/github/squi2rel/vp/video/Degree360Player.java +++ b/src/client/java/com/github/squi2rel/vp/video/Degree360Player.java @@ -4,21 +4,21 @@ import com.github.squi2rel.vp.vivecraft.Vivecraft; import com.mojang.blaze3d.buffers.GpuBuffer; import com.mojang.blaze3d.systems.RenderSystem; +import com.mojang.blaze3d.vertex.BufferBuilder; +import com.mojang.blaze3d.vertex.ByteBufferBuilder; +import com.mojang.blaze3d.vertex.DefaultVertexFormat; +import com.mojang.blaze3d.vertex.MeshData; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; import com.mojang.blaze3d.vertex.VertexFormat; -import net.minecraft.client.render.BufferBuilder; -import net.minecraft.client.render.BuiltBuffer; -import net.minecraft.client.render.VertexConsumer; -import net.minecraft.client.render.VertexConsumerProvider; -import net.minecraft.client.render.VertexFormats; -import net.minecraft.client.util.BufferAllocator; -import net.minecraft.client.util.math.MatrixStack; -import net.minecraft.util.math.MathHelper; import org.joml.Matrix4f; import org.joml.Quaternionf; import org.joml.Vector3f; import java.util.LinkedHashMap; import java.util.Map; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.util.Mth; import static com.github.squi2rel.vp.VideoPlayerClient.config; @@ -37,17 +37,17 @@ protected boolean removeEldestEntry(Map.Entry eldest) { private Degree360Player() { } - public static void drawTexture(int textureId, MatrixStack matrices, VertexConsumerProvider consumers, ClientVideoScreen screen) { + public static void drawTexture(int textureId, PoseStack matrices, MultiBufferSource consumers, ClientVideoScreen screen) { drawTexture(textureId, matrices, consumers, screen, screen.stereo3d); } - public static void drawTexture(int textureId, MatrixStack matrices, VertexConsumerProvider consumers, ClientVideoScreen screen, boolean is3d) { + public static void drawTexture(int textureId, PoseStack matrices, MultiBufferSource consumers, ClientVideoScreen screen, boolean is3d) { if (textureId < 0) return; boolean rightEye = is3d && Vivecraft.loaded && Vivecraft.isVRActive() && Vivecraft.isRightEye(); SphereMesh mesh = meshFor(screen, is3d, rightEye); if (mesh == null || mesh.vertexBuffer == null || mesh.vertexBuffer.isClosed()) return; - matrices.push(); + matrices.pushPose(); if (screen.sphereSkybox) { ScreenRenderer.skybox = true; } else { @@ -59,8 +59,8 @@ public static void drawTexture(int textureId, MatrixStack matrices, VertexConsum ); } applySphereRotation(matrices, screen.sphereRotX, screen.sphereRotY, screen.sphereRotZ); - Matrix4f matrix = new Matrix4f(matrices.peek().getPositionMatrix()); - matrices.pop(); + Matrix4f matrix = new Matrix4f(matrices.last().pose()); + matrices.popPose(); int gray = (int) (config.brightness / 100.0 * 255); int color = 0xFF000000 | (gray << 16) | (gray << 8) | gray; @@ -91,17 +91,17 @@ private static SphereMesh buildMesh(MeshKey key, int latSegments, int lonSegment ? genHemisphereVertices(key.radius(), latSegments, lonSegments, key.u1(), key.u2(), key.v1(), key.v2()) : genVertices(key.radius(), latSegments, lonSegments, key.u1(), key.u2(), key.v1(), key.v2()); int vertexCount = stripVertexCount(latSegments, lonSegments); - int size = Math.max(256, vertexCount * VertexFormats.POSITION_TEXTURE_COLOR.getVertexSize()); - try (BufferAllocator allocator = new BufferAllocator(size)) { - BufferBuilder buffer = new BufferBuilder(allocator, VertexFormat.DrawMode.TRIANGLE_STRIP, VertexFormats.POSITION_TEXTURE_COLOR); + int size = Math.max(256, vertexCount * DefaultVertexFormat.POSITION_TEX_COLOR.getVertexSize()); + try (ByteBufferBuilder allocator = new ByteBufferBuilder(size)) { + BufferBuilder buffer = new BufferBuilder(allocator, VertexFormat.Mode.TRIANGLE_STRIP, DefaultVertexFormat.POSITION_TEX_COLOR); appendSphereStrip(buffer, vertices, latSegments, lonSegments, key.stereo3d, key.rightEye, key.u1(), key.u2()); - try (BuiltBuffer built = buffer.end()) { + try (MeshData built = buffer.buildOrThrow()) { GpuBuffer vertexBuffer = RenderSystem.getDevice().createBuffer( () -> "VideoPlayer 360 sphere mesh", GpuBuffer.USAGE_VERTEX | GpuBuffer.USAGE_COPY_DST, - built.getBuffer() + built.vertexBuffer() ); - return new SphereMesh(vertexBuffer, built.getDrawParameters().vertexCount()); + return new SphereMesh(vertexBuffer, built.drawState().vertexCount()); } } } @@ -138,21 +138,21 @@ private static void appendSphereVertex(VertexConsumer consumer, float[] vertices float split = (u1 + u2) * 0.5f; u = rightEye ? split + (u - u1) * 0.5f : u1 + (u - u1) * 0.5f; } - consumer.vertex(vertices[idx], vertices[idx + 1], vertices[idx + 2]) - .texture(u, vertices[idx + 4]) - .color(0xFFFFFFFF); + consumer.addVertex(vertices[idx], vertices[idx + 1], vertices[idx + 2]) + .setUv(u, vertices[idx + 4]) + .setColor(0xFFFFFFFF); } - private static void flush(VertexConsumerProvider consumers) { - if (consumers instanceof VertexConsumerProvider.Immediate immediate) { - immediate.draw(); + private static void flush(MultiBufferSource consumers) { + if (consumers instanceof MultiBufferSource.BufferSource immediate) { + immediate.endBatch(); } } - private static void applySphereRotation(MatrixStack matrices, float x, float y, float z) { - if (y != 0) matrices.multiply(tmp.rotationY((float) Math.toRadians(y))); - if (x != 0) matrices.multiply(tmp.rotationX((float) Math.toRadians(x))); - if (z != 0) matrices.multiply(tmp.rotationZ((float) Math.toRadians(z))); + private static void applySphereRotation(PoseStack matrices, float x, float y, float z) { + if (y != 0) matrices.mulPose(tmp.rotationY((float) Math.toRadians(y))); + if (x != 0) matrices.mulPose(tmp.rotationX((float) Math.toRadians(x))); + if (z != 0) matrices.mulPose(tmp.rotationZ((float) Math.toRadians(z))); } static float[] genVertices(float radius, int latSegments, int lonSegments, float us, float ue, float vs, float ve) { @@ -191,9 +191,9 @@ private static float[] genVertices(float radius, int latSegments, int lonSegment float x2 = (float) (r2 * Math.cos(phi)); float z1 = (float) (r1 * Math.sin(phi)); float z2 = (float) (r2 * Math.sin(phi)); - float u = MathHelper.lerp((float) lon / lonSegments, us, ue); - float v1 = MathHelper.lerp((float) lat / latSegments, vs, ve); - float v2 = MathHelper.lerp((float) (lat + 1) / latSegments, vs, ve); + float u = Mth.lerp((float) lon / lonSegments, us, ue); + float v1 = Mth.lerp((float) lat / latSegments, vs, ve); + float v2 = Mth.lerp((float) (lat + 1) / latSegments, vs, ve); data[idx++] = x1; data[idx++] = y1; data[idx++] = z1; diff --git a/src/client/java/com/github/squi2rel/vp/video/EntityCameraPlayer.java b/src/client/java/com/github/squi2rel/vp/video/EntityCameraPlayer.java index 28074df..51208ba 100644 --- a/src/client/java/com/github/squi2rel/vp/video/EntityCameraPlayer.java +++ b/src/client/java/com/github/squi2rel/vp/video/EntityCameraPlayer.java @@ -3,10 +3,9 @@ import com.github.squi2rel.vp.CameraRenderer; import com.github.squi2rel.vp.provider.EntityViewProvider; import com.github.squi2rel.vp.provider.VideoInfo; -import net.minecraft.client.MinecraftClient; -import net.minecraft.entity.Entity; - import java.util.UUID; +import net.minecraft.client.Minecraft; +import net.minecraft.world.entity.Entity; public class EntityCameraPlayer extends AbstractCameraPlayer { private Entity entity; @@ -51,10 +50,10 @@ public void onMetaChanged() { } private static Entity findEntity(UUID uuid) { - MinecraftClient client = MinecraftClient.getInstance(); - if (uuid == null || client.world == null) return null; - for (Entity candidate : client.world.getEntities()) { - if (uuid.equals(candidate.getUuid())) return candidate; + Minecraft client = Minecraft.getInstance(); + if (uuid == null || client.level == null) return null; + for (Entity candidate : client.level.entitiesForRendering()) { + if (uuid.equals(candidate.getUUID())) return candidate; } return null; } diff --git a/src/client/java/com/github/squi2rel/vp/video/ExternalGlTexture.java b/src/client/java/com/github/squi2rel/vp/video/ExternalGlTexture.java index 92582be..a7e333c 100644 --- a/src/client/java/com/github/squi2rel/vp/video/ExternalGlTexture.java +++ b/src/client/java/com/github/squi2rel/vp/video/ExternalGlTexture.java @@ -1,20 +1,20 @@ package com.github.squi2rel.vp.video; +import com.mojang.blaze3d.opengl.GlTexture; +import com.mojang.blaze3d.opengl.GlTextureView; import com.mojang.blaze3d.systems.RenderSystem; import com.mojang.blaze3d.textures.AddressMode; import com.mojang.blaze3d.textures.FilterMode; import com.mojang.blaze3d.textures.GpuTexture; import com.mojang.blaze3d.textures.TextureFormat; -import net.minecraft.client.texture.AbstractTexture; -import net.minecraft.client.texture.GlTexture; -import net.minecraft.client.texture.GlTextureView; +import net.minecraft.client.renderer.texture.AbstractTexture; public final class ExternalGlTexture extends AbstractTexture { public ExternalGlTexture(int glId, int width, int height) { WrappedTexture texture = new WrappedTexture(glId, width, height); - this.glTexture = texture; - this.glTextureView = new WrappedTextureView(texture); - this.sampler = RenderSystem.getSamplerCache().get( + this.texture = texture; + this.textureView = new WrappedTextureView(texture); + this.sampler = RenderSystem.getSamplerCache().getSampler( AddressMode.CLAMP_TO_EDGE, AddressMode.CLAMP_TO_EDGE, FilterMode.LINEAR, @@ -25,11 +25,11 @@ public ExternalGlTexture(int glId, int width, int height) { @Override public void close() { - if (glTexture instanceof WrappedTexture wrapped) { + if (texture instanceof WrappedTexture wrapped) { wrapped.markClosed(); } - glTexture = null; - glTextureView = null; + texture = null; + textureView = null; } private static final class WrappedTexture extends GlTexture { diff --git a/src/client/java/com/github/squi2rel/vp/video/FramebufferBackedTexture.java b/src/client/java/com/github/squi2rel/vp/video/FramebufferBackedTexture.java index b1aaa24..4fa9cf4 100644 --- a/src/client/java/com/github/squi2rel/vp/video/FramebufferBackedTexture.java +++ b/src/client/java/com/github/squi2rel/vp/video/FramebufferBackedTexture.java @@ -1,21 +1,21 @@ package com.github.squi2rel.vp.video; +import com.mojang.blaze3d.pipeline.RenderTarget; import com.mojang.blaze3d.systems.RenderSystem; import com.mojang.blaze3d.textures.AddressMode; import com.mojang.blaze3d.textures.FilterMode; -import net.minecraft.client.gl.Framebuffer; -import net.minecraft.client.texture.AbstractTexture; +import net.minecraft.client.renderer.texture.AbstractTexture; public final class FramebufferBackedTexture extends AbstractTexture { - private final Framebuffer framebuffer; + private final RenderTarget framebuffer; - public FramebufferBackedTexture(Framebuffer framebuffer) { + public FramebufferBackedTexture(RenderTarget framebuffer) { this(framebuffer, FilterMode.LINEAR); } - public FramebufferBackedTexture(Framebuffer framebuffer, FilterMode filterMode) { + public FramebufferBackedTexture(RenderTarget framebuffer, FilterMode filterMode) { this.framebuffer = framebuffer; - this.sampler = RenderSystem.getSamplerCache().get( + this.sampler = RenderSystem.getSamplerCache().getSampler( AddressMode.CLAMP_TO_EDGE, AddressMode.CLAMP_TO_EDGE, filterMode, @@ -26,14 +26,14 @@ public FramebufferBackedTexture(Framebuffer framebuffer, FilterMode filterMode) } public void updateAttachment() { - this.glTexture = framebuffer.getColorAttachment(); - this.glTextureView = framebuffer.getColorAttachmentView(); + this.texture = framebuffer.getColorTexture(); + this.textureView = framebuffer.getColorTextureView(); } @Override public void close() { - framebuffer.delete(); - glTexture = null; - glTextureView = null; + framebuffer.destroyBuffers(); + texture = null; + textureView = null; } } diff --git a/src/client/java/com/github/squi2rel/vp/video/IVideoPlayer.java b/src/client/java/com/github/squi2rel/vp/video/IVideoPlayer.java index 2e4c447..ffbdf7f 100644 --- a/src/client/java/com/github/squi2rel/vp/video/IVideoPlayer.java +++ b/src/client/java/com/github/squi2rel/vp/video/IVideoPlayer.java @@ -1,9 +1,9 @@ package com.github.squi2rel.vp.video; import com.github.squi2rel.vp.provider.VideoInfo; -import net.minecraft.client.render.VertexConsumer; -import net.minecraft.client.render.VertexConsumerProvider; -import net.minecraft.client.util.math.MatrixStack; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import net.minecraft.client.renderer.MultiBufferSource; import org.jetbrains.annotations.Nullable; import org.joml.Matrix4f; import org.joml.Vector2f; @@ -100,7 +100,7 @@ default boolean flippedY() { return false; } - default void draw(MatrixStack matrices, VertexConsumerProvider consumers, ClientVideoScreen s) { + default void draw(PoseStack matrices, MultiBufferSource consumers, ClientVideoScreen s) { VideoPlayerRenderer.draw(this, matrices, consumers, s); } diff --git a/src/client/java/com/github/squi2rel/vp/video/MpvVideoBackend.java b/src/client/java/com/github/squi2rel/vp/video/MpvVideoBackend.java index e74d20d..e575810 100644 --- a/src/client/java/com/github/squi2rel/vp/video/MpvVideoBackend.java +++ b/src/client/java/com/github/squi2rel/vp/video/MpvVideoBackend.java @@ -1,5 +1,6 @@ package com.github.squi2rel.vp.video; +import com.github.squi2rel.vp.ScreenRenderer; import com.github.squi2rel.vp.VideoPlayerMain; import com.github.squi2rel.vp.VideoPlayerClient; import com.github.squi2rel.vp.filtergraph.MpvLavfiFilterCatalog; @@ -9,7 +10,6 @@ import com.sun.jna.Native; import com.sun.jna.Pointer; import com.sun.jna.ptr.PointerByReference; -import net.minecraft.client.MinecraftClient; import org.lwjgl.BufferUtils; import org.lwjgl.PointerBuffer; import org.lwjgl.opengl.GL; @@ -25,6 +25,7 @@ import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.BiConsumer; +import net.minecraft.client.Minecraft; import static com.github.squi2rel.vp.video.MpvLibrary.*; import static org.lwjgl.glfw.GLFW.*; @@ -40,6 +41,7 @@ import static org.lwjgl.system.MemoryUtil.NULL; import static org.lwjgl.system.MemoryUtil.memUTF8; + public class MpvVideoBackend implements VideoBackend { private static final int INITIAL_SIZE = 1; private static final int PROPERTY_POLL_INTERVAL_MS = 100; @@ -413,6 +415,7 @@ public void cleanup() { ACTIVE_BACKENDS.remove(this); if (!released.compareAndSet(false, true)) return; acceptingFrames.set(false); + releaseRegisteredTextures(); discardPendingPlayback(); tasks.clear(); if (singleContext) { @@ -425,8 +428,8 @@ public void cleanup() { Pointer ctx = handle; if (ctx != null) lib.mpv_wakeup(ctx); discardPendingReadySyncOnRenderThread(); - MinecraftClient client = MinecraftClient.getInstance(); - if (!client.isOnThread()) { + Minecraft client = Minecraft.getInstance(); + if (!client.isSameThread()) { joinRenderThread(RENDER_THREAD_JOIN_MS); } } @@ -444,7 +447,7 @@ private void discardPendingPlayback() { } private void cleanupSingleContextRenderer() { - MinecraftClient client = MinecraftClient.getInstance(); + Minecraft client = Minecraft.getInstance(); Runnable cleanup = () -> { try { runSingleContextCleanup(); @@ -456,7 +459,7 @@ private void cleanupSingleContextRenderer() { if (ctx != null) lib.mpv_wakeup(ctx); } }; - if (client.isOnThread()) { + if (client.isSameThread()) { cleanup.run(); return; } @@ -473,9 +476,9 @@ private void runSingleContextCleanup() { } private void discardPendingReadySyncOnRenderThread() { - MinecraftClient client = MinecraftClient.getInstance(); + Minecraft client = Minecraft.getInstance(); Runnable discard = this::discardPendingReadySync; - if (client.isOnThread()) { + if (client.isSameThread()) { discard.run(); return; } @@ -706,7 +709,7 @@ private void seek(Pointer ctx, long progress) { } private long createSharedWindow() { - long share = MinecraftClient.getInstance().getWindow().getHandle(); + long share = Minecraft.getInstance().getWindow().handle(); glfwDefaultWindowHints(); try { glfwWindowHint(GLFW_VISIBLE, GLFW_FALSE); @@ -789,7 +792,7 @@ private void renderLoop(CompletableFuture ready) { } private void notifySize(int w, int h) { - MinecraftClient.getInstance().execute(() -> sizeListener.accept(w, h)); + Minecraft.getInstance().execute(() -> sizeListener.accept(w, h)); } private void signalRenderThread() { @@ -799,9 +802,9 @@ private void signalRenderThread() { } private void destroySharedWindow(long window) { - MinecraftClient client = MinecraftClient.getInstance(); + Minecraft client = Minecraft.getInstance(); Runnable destroy = () -> glfwDestroyWindow(window); - if (client.isOnThread()) { + if (client.isSameThread()) { destroy.run(); } else { client.execute(destroy); @@ -1265,6 +1268,7 @@ private void cleanupTexture() { fboIds[i] = -1; } if (textureIds[i] >= 0) { + ScreenRenderer.releaseTexture(textureIds[i]); glDeleteTextures(textureIds[i]); textureIds[i] = -1; } @@ -1272,6 +1276,12 @@ private void cleanupTexture() { renderTextureIndex = 0; } + private void releaseRegisteredTextures() { + for (int textureId : textureIds) { + ScreenRenderer.releaseTexture(textureId); + } + } + private void clearPublishedTexture() { long sync; synchronized (publishLock) { diff --git a/src/client/java/com/github/squi2rel/vp/video/PBOManager.java b/src/client/java/com/github/squi2rel/vp/video/PBOManager.java index 88d30e5..4252d67 100644 --- a/src/client/java/com/github/squi2rel/vp/video/PBOManager.java +++ b/src/client/java/com/github/squi2rel/vp/video/PBOManager.java @@ -1,13 +1,13 @@ package com.github.squi2rel.vp.video; import com.github.squi2rel.vp.VideoPlayerMain; -import net.minecraft.client.MinecraftClient; import org.lwjgl.opengl.GL; import org.lwjgl.opengl.GLCapabilities; import java.util.Arrays; import java.nio.ByteBuffer; import java.util.concurrent.locks.ReentrantLock; +import net.minecraft.client.Minecraft; import static org.lwjgl.opengl.ARBBufferStorage.GL_DYNAMIC_STORAGE_BIT; import static org.lwjgl.opengl.ARBBufferStorage.GL_MAP_COHERENT_BIT; @@ -122,7 +122,7 @@ public void release() { lock.lock(); if (!allocated) return; BufferState state = detach(); - MinecraftClient.getInstance().execute(() -> destroy(state)); + Minecraft.getInstance().execute(() -> destroy(state)); } finally { lock.unlock(); } diff --git a/src/client/java/com/github/squi2rel/vp/video/UnavailableVideoBackend.java b/src/client/java/com/github/squi2rel/vp/video/UnavailableVideoBackend.java index 5503772..1444347 100644 --- a/src/client/java/com/github/squi2rel/vp/video/UnavailableVideoBackend.java +++ b/src/client/java/com/github/squi2rel/vp/video/UnavailableVideoBackend.java @@ -2,9 +2,9 @@ import com.github.squi2rel.vp.i18n.VpTexts; import com.github.squi2rel.vp.provider.VideoInfo; -import net.minecraft.client.MinecraftClient; -import net.minecraft.client.network.ClientPlayerEntity; -import net.minecraft.util.Formatting; +import net.minecraft.ChatFormatting; +import net.minecraft.client.Minecraft; +import net.minecraft.client.player.LocalPlayer; import static com.github.squi2rel.vp.VideoPlayerMain.LOGGER; @@ -30,13 +30,13 @@ public void play(VideoInfo info, long targetTime, int volume) { if (warned) return; warned = true; LOGGER.warn("No available video backend for requested backend {}", requestedBackend); - MinecraftClient client = MinecraftClient.getInstance(); - ClientPlayerEntity player = client == null ? null : client.player; + Minecraft client = Minecraft.getInstance(); + LocalPlayer player = client == null ? null : client.player; if (player != null) { - player.sendMessage(VpTexts.tr( + player.displayClientMessage(VpTexts.tr( "error.videoplayer.local_backend_unavailable", "Neither local MPV nor VLC is available. Open /videoplayer boot to install or repair a video runtime." - ).formatted(Formatting.RED), false); + ).withStyle(ChatFormatting.RED), false); } } diff --git a/src/client/java/com/github/squi2rel/vp/video/VideoPlayer.java b/src/client/java/com/github/squi2rel/vp/video/VideoPlayer.java index 945deb3..e664d18 100644 --- a/src/client/java/com/github/squi2rel/vp/video/VideoPlayer.java +++ b/src/client/java/com/github/squi2rel/vp/video/VideoPlayer.java @@ -2,14 +2,16 @@ import com.github.squi2rel.vp.provider.VideoInfo; import com.github.squi2rel.vp.vivecraft.Vivecraft; -import net.minecraft.client.render.VertexConsumer; -import net.minecraft.client.render.VertexConsumerProvider; -import net.minecraft.client.util.math.MatrixStack; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import net.minecraft.client.renderer.MultiBufferSource; import org.joml.Matrix4f; import org.joml.Vector2f; import org.joml.Vector3f; import static com.github.squi2rel.vp.VideoPlayerMain.LOGGER; + + import static com.github.squi2rel.vp.VideoPlayerClient.config; public class VideoPlayer extends AbstractScreenPlayer implements RateAdjustablePlayer, MetaListener { @@ -226,7 +228,7 @@ public boolean isPostUpdate() { } @Override - public void draw(MatrixStack matrices, VertexConsumerProvider consumers, ClientVideoScreen s) { + public void draw(PoseStack matrices, MultiBufferSource consumers, ClientVideoScreen s) { VideoPlayerRenderer.draw(this, matrices, consumers, s); if (s.surface == ScreenSurface.SPHERE_360 && s.spherePreset && getTextureId() >= 0) { Degree360Player.drawTexture(getTextureId(), matrices, consumers, s, is3d); diff --git a/src/client/java/com/github/squi2rel/vp/video/VideoPlayerRenderer.java b/src/client/java/com/github/squi2rel/vp/video/VideoPlayerRenderer.java index e687c28..8f7ee20 100644 --- a/src/client/java/com/github/squi2rel/vp/video/VideoPlayerRenderer.java +++ b/src/client/java/com/github/squi2rel/vp/video/VideoPlayerRenderer.java @@ -1,16 +1,16 @@ package com.github.squi2rel.vp.video; import com.github.squi2rel.vp.ScreenRenderer; -import net.minecraft.client.render.RenderLayer; -import net.minecraft.client.render.VertexConsumer; -import net.minecraft.client.render.VertexConsumerProvider; -import net.minecraft.client.util.math.MatrixStack; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; import org.joml.Matrix4f; import org.joml.Vector2f; import org.joml.Vector3f; import java.util.ArrayList; import java.util.List; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.rendertype.RenderType; import static com.github.squi2rel.vp.VideoPlayerClient.config; @@ -22,7 +22,7 @@ final class VideoPlayerRenderer { private VideoPlayerRenderer() { } - static void draw(IVideoPlayer player, MatrixStack matrices, VertexConsumerProvider consumers, ClientVideoScreen target) { + static void draw(IVideoPlayer player, PoseStack matrices, MultiBufferSource consumers, ClientVideoScreen target) { ClientVideoScreen source = player.screen(); if (source == null || source.player == null) return; if (player.getTextureId() < 0) return; @@ -35,10 +35,10 @@ static void draw(IVideoPlayer player, MatrixStack matrices, VertexConsumerProvid } Vector3f relativeOrigin = geometry.relativeOrigin(ScreenRenderer.preciseCameraX, ScreenRenderer.preciseCameraY, ScreenRenderer.preciseCameraZ); - matrices.push(); + matrices.pushPose(); matrices.translate(relativeOrigin.x, relativeOrigin.y, relativeOrigin.z); - Matrix4f mat = matrices.peek().getPositionMatrix(); - matrices.pop(); + Matrix4f mat = matrices.last().pose(); + matrices.popPose(); boolean fx = player.flippedX(); boolean fy = player.flippedY(); @@ -62,7 +62,7 @@ static void draw(IVideoPlayer player, MatrixStack matrices, VertexConsumerProvid drawBackingTriangle(mat, backingConsumer, target, geometry, vertices, triangles, i, bounds, mappedUvs, normal); } - RenderLayer layer = ScreenRenderer.getLayer(player.getTextureId()); + RenderType layer = ScreenRenderer.getLayer(player.getTextureId()); VertexConsumer consumer = consumers.getBuffer(layer); for (int i = 0; i < triangles.length; i += 3) { drawTriangle(player, mat, consumer, target, geometry, vertices, triangles, i, bounds, fx, fy, mappedUvs, normal); @@ -70,7 +70,7 @@ static void draw(IVideoPlayer player, MatrixStack matrices, VertexConsumerProvid } static void drawTexture(int textureId, int textureWidth, int textureHeight, - MatrixStack matrices, VertexConsumerProvider consumers, ClientVideoScreen target) { + PoseStack matrices, MultiBufferSource consumers, ClientVideoScreen target) { if (textureId < 0) return; ScreenGeometry geometry; @@ -81,10 +81,10 @@ static void drawTexture(int textureId, int textureWidth, int textureHeight, } Vector3f relativeOrigin = geometry.relativeOrigin(ScreenRenderer.preciseCameraX, ScreenRenderer.preciseCameraY, ScreenRenderer.preciseCameraZ); - matrices.push(); + matrices.pushPose(); matrices.translate(relativeOrigin.x, relativeOrigin.y, relativeOrigin.z); - Matrix4f mat = matrices.peek().getPositionMatrix(); - matrices.pop(); + Matrix4f mat = matrices.last().pose(); + matrices.popPose(); float[] bounds = geometry.contentBounds( target.u1, @@ -106,7 +106,7 @@ static void drawTexture(int textureId, int textureWidth, int textureHeight, drawBackingTriangle(mat, backingConsumer, target, geometry, vertices, triangles, i, bounds, mappedUvs, normal); } - RenderLayer layer = ScreenRenderer.getLayer(textureId); + RenderType layer = ScreenRenderer.getLayer(textureId); VertexConsumer consumer = consumers.getBuffer(layer); for (int i = 0; i < triangles.length; i += 3) { drawTextureTriangle(mat, consumer, target, geometry, vertices, triangles, i, bounds, mappedUvs, normal); diff --git a/src/client/java/com/github/squi2rel/vp/video/VideoQuad.java b/src/client/java/com/github/squi2rel/vp/video/VideoQuad.java index 4f593ca..3b476c3 100644 --- a/src/client/java/com/github/squi2rel/vp/video/VideoQuad.java +++ b/src/client/java/com/github/squi2rel/vp/video/VideoQuad.java @@ -1,10 +1,11 @@ package com.github.squi2rel.vp.video; -import net.minecraft.client.MinecraftClient; +import com.github.squi2rel.vp.ScreenRenderer; import org.lwjgl.opengl.GL; import org.lwjgl.opengl.GLCapabilities; import java.nio.ByteBuffer; +import net.minecraft.client.Minecraft; import static org.lwjgl.opengl.GL21.*; import static org.lwjgl.opengl.GL12.GL_BGRA; @@ -85,7 +86,8 @@ private void forceOpaqueAlpha() { public void cleanup() { if (textureInitialized) { - MinecraftClient.getInstance().execute(() -> { + Minecraft.getInstance().execute(() -> { + ScreenRenderer.releaseTexture(textureId); glDeleteTextures(textureId); pbo.release(); }); diff --git a/src/main/java/com/github/squi2rel/vp/DataHolder.java b/src/main/java/com/github/squi2rel/vp/DataHolder.java index 64e8d98..9fde6cf 100644 --- a/src/main/java/com/github/squi2rel/vp/DataHolder.java +++ b/src/main/java/com/github/squi2rel/vp/DataHolder.java @@ -1,6 +1,7 @@ package com.github.squi2rel.vp; import com.github.squi2rel.vp.network.ServerPacketHandler; +import com.github.squi2rel.vp.network.ClientMessageBridge; import com.github.squi2rel.vp.network.VideoHandshakeState; import com.github.squi2rel.vp.network.VideoPackets; import com.github.squi2rel.vp.i18n.MinecraftTexts; @@ -9,14 +10,13 @@ import com.github.squi2rel.vp.video.ScreenKey; import com.google.gson.Gson; import net.fabricmc.fabric.api.networking.v1.PlayerLookup; +import net.minecraft.ChatFormatting; import net.minecraft.server.MinecraftServer; -import net.minecraft.server.PlayerManager; -import net.minecraft.server.network.ServerPlayerEntity; -import net.minecraft.server.world.ServerWorld; -import net.minecraft.util.WorldSavePath; -import net.minecraft.util.Formatting; -import net.minecraft.world.dimension.DimensionType; - +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.players.PlayerList; +import net.minecraft.world.level.dimension.DimensionType; +import net.minecraft.world.level.storage.LevelResource; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -67,64 +67,64 @@ public class DataHolder { public static void update() { MinecraftServer current = server; if (!running || current == null) return; - PlayerManager pm = current.getPlayerManager(); + PlayerList pm = current.getPlayerList(); ArrayList notifications = new ArrayList<>(); for (Map.Entry entry : new ArrayList<>(playerDim.entrySet())) { - ServerPlayerEntity player = pm.getPlayer(entry.getKey()); + ServerPlayer player = pm.getPlayer(entry.getKey()); if (player == null) continue; - String dim = player.getEntityWorld().getRegistryKey().getValue().toString(); + String dim = player.level().dimension().identifier().toString(); if (dim.equals(entry.getValue())) continue; HashMap map = areas.get(entry.getValue()); if (map == null) continue; for (VideoArea area : map.values()) { - if (area.removePlayer(player.getUuid())) { + if (area.removePlayer(player.getUUID())) { ServerPacketHandler.sendTo(player, VideoPackets.unloadArea(area)); ServerPacketHandler.sendTo(player, VideoPackets.removeArea(area)); } } } for (UUID uuid : allPlayers) { - ServerPlayerEntity player = pm.getPlayer(uuid); + ServerPlayer player = pm.getPlayer(uuid); if (player == null) continue; - String dim = player.getEntityWorld().getRegistryKey().getValue().toString(); + String dim = player.level().dimension().identifier().toString(); HashMap all = areas.get(dim); if (all == null) { - loadWorld(current, player.getEntityWorld()); + loadWorld(current, player.level()); all = areas.get(dim); } if (all == null || all.isEmpty()) continue; for (VideoArea area : all.values()) { - if (area.inBounds(player.getEntityPos())) { - if (area.addPlayer(player.getUuid())) { + if (area.inBounds(player.position())) { + if (area.addPlayer(player.getUUID())) { sendAreaSnapshot(player, area); area.playerEntered(); - notifications.add(() -> player.sendMessage(MinecraftTexts.tr( + notifications.add(() -> ClientMessageBridge.sendOverlay(player, MinecraftTexts.tr( "message.videoplayer.area_enter", "Entered video area %s", area.name - ).formatted(Formatting.DARK_AQUA), true)); + ).withStyle(ChatFormatting.DARK_AQUA))); } } else { - if (area.removePlayer(player.getUuid())) { + if (area.removePlayer(player.getUUID())) { notifications.add(() -> ServerPacketHandler.sendTo(player, VideoPackets.unloadArea(area))); notifications.add(() -> ServerPacketHandler.sendTo(player, VideoPackets.removeArea(area))); - notifications.add(() -> player.sendMessage(MinecraftTexts.tr( + notifications.add(() -> ClientMessageBridge.sendOverlay(player, MinecraftTexts.tr( "message.videoplayer.area_leave", "Left video area %s", area.name - ).formatted(Formatting.DARK_AQUA), true)); + ).withStyle(ChatFormatting.DARK_AQUA))); } } } } - for (ServerPlayerEntity player : PlayerLookup.all(current)) { - playerDim.put(player.getUuid(), player.getEntityWorld().getRegistryKey().getValue().toString()); + for (ServerPlayer player : PlayerLookup.all(current)) { + playerDim.put(player.getUUID(), player.level().dimension().identifier().toString()); } notifications.forEach(Runnable::run); } public static void unload(MinecraftServer s) { - PlayerManager pm = s.getPlayerManager(); + PlayerList pm = s.getPlayerList(); for (HashMap map : areas.values()) { for (VideoArea area : map.values()) { unloadArea(pm, area); @@ -133,12 +133,12 @@ public static void unload(MinecraftServer s) { } } - public static void playerJoin(ServerPlayerEntity player) { + public static void playerJoin(ServerPlayer player) { if (!running || player == null) return; - handshakes.put(player.getUuid(), VideoHandshakeState.NEEDS_RESET); - handshakeNonces.remove(player.getUuid()); - handshakeTokens.remove(player.getUuid()); - onlinePlayerNames.put(player.getGameProfile().name().toLowerCase(Locale.ROOT), player.getUuid()); + handshakes.put(player.getUUID(), VideoHandshakeState.NEEDS_RESET); + handshakeNonces.remove(player.getUUID()); + handshakeTokens.remove(player.getUUID()); + onlinePlayerNames.put(player.getGameProfile().name().toLowerCase(Locale.ROOT), player.getUUID()); } public static void playerLeave(UUID uuid) { @@ -213,14 +213,14 @@ public static void load(MinecraftServer server) { handshakeNonces.clear(); handshakeTokens.clear(); onlinePlayerNames.clear(); - for (ServerWorld world : server.getWorlds()) { + for (ServerLevel world : server.getAllLevels()) { loadWorld(server, world); } } - public static void loadWorld(MinecraftServer server, ServerWorld world) { + public static void loadWorld(MinecraftServer server, ServerLevel world) { if (!running || server == null || world == null || DataHolder.server != server) return; - String dim = world.getRegistryKey().getValue().toString(); + String dim = world.dimension().identifier().toString(); if (areas.containsKey(dim)) return; Path path = worldDirectory(server, world).resolve("videoplayer.json"); @@ -253,9 +253,9 @@ public static void loadWorld(MinecraftServer server, ServerWorld world) { VideoPlayerMain.LOGGER.info("Loaded {} VideoPlayer areas for world {} from {}", map.size(), dim, path); } - public static void unloadWorld(MinecraftServer server, ServerWorld world) { + public static void unloadWorld(MinecraftServer server, ServerLevel world) { if (world == null) return; - String dim = world.getRegistryKey().getValue().toString(); + String dim = world.dimension().identifier().toString(); try { saveWorld(dim); } catch (RuntimeException error) { @@ -264,7 +264,7 @@ public static void unloadWorld(MinecraftServer server, ServerWorld world) { } HashMap map = areas.remove(dim); if (map != null) { - PlayerManager pm = server == null ? null : server.getPlayerManager(); + PlayerList pm = server == null ? null : server.getPlayerList(); for (VideoArea area : map.values()) { unloadArea(pm, area); area.remove(); @@ -518,18 +518,18 @@ private static void prepareArea(VideoArea area) { area.afterLoad(); } - private static void unloadArea(PlayerManager pm, VideoArea area) { + private static void unloadArea(PlayerList pm, VideoArea area) { if (pm == null || area == null || !area.hasPlayer()) return; byte[] unload = VideoPackets.unloadArea(area); byte[] remove = VideoPackets.removeArea(area); for (UUID uuid : area.playerSnapshot()) { - ServerPlayerEntity player = pm.getPlayer(uuid); + ServerPlayer player = pm.getPlayer(uuid); ServerPacketHandler.sendTo(player, unload); ServerPacketHandler.sendTo(player, remove); } } - private static void sendAreaSnapshot(ServerPlayerEntity player, VideoArea area) { + private static void sendAreaSnapshot(ServerPlayer player, VideoArea area) { ServerPacketHandler.sendTo(player, VideoPackets.createArea(area)); ServerPacketHandler.sendAreaPermissions(player, area); for (VideoScreen screen : area.screens) { @@ -539,7 +539,7 @@ private static void sendAreaSnapshot(ServerPlayerEntity player, VideoArea area) } if (!screen.idlePlayEntries.isEmpty() || screen.idlePlayRandom) { ServerPacketHandler.sendTo(player, VideoPackets.idlePlay( - screen, supportsIdlePlayMutations(player.getUuid()) + screen, supportsIdlePlayMutations(player.getUUID()) )); } } @@ -580,8 +580,8 @@ private static void applySharedConfig(ServerConfig loaded) { config.noControlRange = loaded.noControlRange; } - private static Path worldDirectory(MinecraftServer server, ServerWorld world) { - return DimensionType.getSaveDirectory(world.getRegistryKey(), server.getSavePath(WorldSavePath.ROOT)); + private static Path worldDirectory(MinecraftServer server, ServerLevel world) { + return DimensionType.getStorageFolder(world.dimension(), server.getWorldPath(LevelResource.ROOT)); } public static String readString(Path path) { @@ -687,9 +687,9 @@ public static boolean supportsIdlePlayMutations(UUID uuid) { && com.github.squi2rel.vp.network.VideoProtocol.supportsIdlePlayMutations(handshakeToken(uuid)); } - public static void refreshPlayerProtocol(ServerPlayerEntity player) { - if (player == null || !protocolActive(player.getUuid())) return; - UUID uuid = player.getUuid(); + public static void refreshPlayerProtocol(ServerPlayer player) { + if (player == null || !protocolActive(player.getUUID())) return; + UUID uuid = player.getUUID(); boolean mutations = supportsIdlePlayMutations(uuid); for (HashMap world : areas.values()) { for (VideoArea area : world.values()) { @@ -730,8 +730,8 @@ public static void message(UUID uuid, long epoch, String message) { if (current == null || message == null) return; current.execute(() -> { if (!lifecycleActive(epoch)) return; - ServerPlayerEntity player = current.getPlayerManager().getPlayer(uuid); - if (player != null) player.sendMessage(net.minecraft.text.Text.of(message)); + ServerPlayer player = current.getPlayerList().getPlayer(uuid); + if (player != null) player.sendSystemMessage(net.minecraft.network.chat.Component.nullToEmpty(message)); }); } @@ -740,8 +740,8 @@ public static void message(UUID uuid, long epoch, com.github.squi2rel.vp.i18n.Vp if (current == null || message == null) return; current.execute(() -> { if (!lifecycleActive(epoch)) return; - ServerPlayerEntity player = current.getPlayerManager().getPlayer(uuid); - if (player != null) player.sendMessage(MinecraftTexts.text(message)); + ServerPlayer player = current.getPlayerList().getPlayer(uuid); + if (player != null) player.sendSystemMessage(MinecraftTexts.text(message)); }); } diff --git a/src/main/java/com/github/squi2rel/vp/LocalPlaybackResolutionPolicy.java b/src/main/java/com/github/squi2rel/vp/LocalPlaybackResolutionPolicy.java new file mode 100644 index 0000000..8378bb0 --- /dev/null +++ b/src/main/java/com/github/squi2rel/vp/LocalPlaybackResolutionPolicy.java @@ -0,0 +1,17 @@ +package com.github.squi2rel.vp; + +import com.github.squi2rel.vp.provider.LocalPlaybackInfo; +import com.github.squi2rel.vp.provider.VideoInfo; +import com.github.squi2rel.vp.provider.YouTubeProvider; + +final class LocalPlaybackResolutionPolicy { + private LocalPlaybackResolutionPolicy() { + } + + static boolean shouldResolve(VideoInfo info) { + if (info == null || info.rawPath() == null || info.rawPath().isBlank()) return false; + return info.seekable() + || !YouTubeProvider.isYouTubeRawPath(info.rawPath()) + || !LocalPlaybackInfo.playable(info); + } +} diff --git a/src/main/java/com/github/squi2rel/vp/NativePackageManager.java b/src/main/java/com/github/squi2rel/vp/NativePackageManager.java index 7350696..dbb2ac8 100644 --- a/src/main/java/com/github/squi2rel/vp/NativePackageManager.java +++ b/src/main/java/com/github/squi2rel/vp/NativePackageManager.java @@ -35,6 +35,7 @@ import java.util.Set; import java.util.UUID; import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ExecutionException; @@ -57,8 +58,6 @@ public final class NativePackageManager { private static final int MAX_ARCHIVE_ENTRIES = 4096; public static final String BACKEND_VLC = NativeDownloadConfig.BACKEND_VLC; public static final String BACKEND_MPV = NativeDownloadConfig.BACKEND_MPV; - public static final String BUNDLED_ANDROID_VLC_RESOURCE = "/assets/videoplayer/native/vlc/android_arm64-v8a.zip"; - public static final String BUNDLED_ANDROID_VLC_SHA256 = "dbae70c264a9d86cd8d7fbd7ca35388cbe973de03636c08f0ac8d7cdb493f9ec"; private static final Path ROOT = configDir().resolve("videoplayer-native"); private static final Path PACKAGE_ROOT = ROOT.resolve("packages"); @@ -78,11 +77,11 @@ public final class NativePackageManager { private static final ConcurrentMap INSTALL_LOCKS = new ConcurrentHashMap<>(); private static final BooleanSupplier ALWAYS_ACTIVE = () -> true; private static final long DOWNLOAD_READ_IDLE_TIMEOUT_MILLIS = 30_000L; - private static final ExecutorService DOWNLOAD_READ_EXECUTOR = Executors.newCachedThreadPool(task -> { - Thread thread = new Thread(task, "VideoPlayer-native-download-read"); - thread.setDaemon(true); - return thread; - }); + private static final long DOWNLOAD_CANCEL_POLL_MILLIS = 100L; + private static final Object DOWNLOAD_READ_EXECUTOR_LOCK = new Object(); + private static final Set> ACTIVE_HTTP_REQUESTS = ConcurrentHashMap.newKeySet(); + private static final Set ACTIVE_RESPONSE_BODIES = ConcurrentHashMap.newKeySet(); + private static ExecutorService downloadReadExecutor; private NativePackageManager() { } @@ -125,6 +124,24 @@ public static void selectPlatform(String backend, String platform) { ); } + static void cancelActiveDownloads() { + for (CompletableFuture request : List.copyOf(ACTIVE_HTTP_REQUESTS)) { + request.cancel(true); + } + for (InputStream input : List.copyOf(ACTIVE_RESPONSE_BODIES)) { + try { + input.close(); + } catch (IOException ignored) { + } + } + ExecutorService executor; + synchronized (DOWNLOAD_READ_EXECUTOR_LOCK) { + executor = downloadReadExecutor; + downloadReadExecutor = null; + } + if (executor != null) executor.shutdownNow(); + } + public static String selectedPlatform(String backend) { return SELECTED_PLATFORMS.getOrDefault(NativeDownloadConfig.normalizeBackend(backend), platformKey()); } @@ -149,34 +166,6 @@ public static boolean isInstalled(String backend, String platform) { return isValidInstallation(installedRoot(normalizedBackend, normalizedPlatform), normalizedBackend, normalizedPlatform); } - public static synchronized boolean ensureBundledAndroidVlc() { - if (!"android".equals(NativeDownloadConfig.osKey())) return true; - String platform = platformKey(); - if (!NativeDownloadConfig.ANDROID_ARM64.equals(platform)) return false; - selectPlatform(BACKEND_VLC, platform); - if (bundledAndroidVlcReady()) return true; - DownloadResult result = installBundled( - BACKEND_VLC, - platform, - BUNDLED_ANDROID_VLC_RESOURCE, - BUNDLED_ANDROID_VLC_SHA256 - ); - if (!result.success()) { - VideoPlayerMain.LOGGER.warn("Failed to install bundled Android VLC runtime", result.error()); - return false; - } - return bundledAndroidVlcReady(); - } - - private static boolean bundledAndroidVlcReady() { - Path root = installedRoot(BACKEND_VLC, NativeDownloadConfig.ANDROID_ARM64); - return isValidInstallation(root, BACKEND_VLC, NativeDownloadConfig.ANDROID_ARM64) - && Files.isRegularFile(root.resolve("libvlc.so"), LinkOption.NOFOLLOW_LINKS) - && Files.isRegularFile(root.resolve("libvlcjni.so"), LinkOption.NOFOLLOW_LINKS) - && Files.isRegularFile(root.resolve("libvlc_jvm_bridge.so"), LinkOption.NOFOLLOW_LINKS) - && Files.isRegularFile(root.resolve("libc++_shared.so"), LinkOption.NOFOLLOW_LINKS); - } - public static DownloadResult downloadAndInstall(String backend, List sources, ProgressListener listener) { return downloadAndInstall(backend, selectedPlatform(backend), sources, listener); } @@ -243,6 +232,7 @@ private static DownloadResult downloadAndInstallLocked(String normalizedBackend, return DownloadResult.fail(VpTranslation.of("error.videoplayer.native.invalid_proxy", "Invalid proxy configuration: %s", e.getMessage()), e); } + boolean proxyConfigured = proxy != null && !proxy.isBlank(); Throwable lastError = null; for (int i = 0; i < usableSources.size(); i++) { NativeDownloadConfig.DownloadSource source = usableSources.get(i); @@ -252,7 +242,7 @@ private static DownloadResult downloadAndInstallLocked(String normalizedBackend, notify(listener, new DownloadProgress(i + 1, usableSources.size(), 0, -1, sourceName, VpTranslation.of("message.videoplayer.native.connecting", "Connecting"))); Path zip = download(http, temporaryDownloadName(normalizedBackend, normalizedPlatform, ".zip.tmp"), source, - i, usableSources.size(), listener, active); + i, usableSources.size(), listener, active, proxyConfigured); checkActive(active); verify(source, zip, active); checkActive(active); @@ -340,6 +330,7 @@ private static DownloadResult downloadAndInstallFileLocked(String name, String p return DownloadResult.fail(VpTranslation.of("error.videoplayer.native.invalid_proxy", "Invalid proxy configuration: %s", e.getMessage()), e); } + boolean proxyConfigured = proxy != null && !proxy.isBlank(); Throwable lastError = null; for (int i = 0; i < usableSources.size(); i++) { NativeDownloadConfig.DownloadSource source = usableSources.get(i); @@ -349,7 +340,7 @@ private static DownloadResult downloadAndInstallFileLocked(String name, String p notify(listener, new DownloadProgress(i + 1, usableSources.size(), 0, -1, selectedSource, VpTranslation.of("message.videoplayer.native.connecting", "Connecting"))); Path downloaded = download(http, temporaryDownloadName(name, platform, ".tmp"), source, - i, usableSources.size(), listener, guard); + i, usableSources.size(), listener, guard, proxyConfigured); checkActive(guard); verify(source, downloaded, guard); checkActive(guard); @@ -424,7 +415,7 @@ public static DownloadResult installBundled(String backend, String platform, Str } public static DownloadResult installBundled(String backend, String platform, String resource, String expectedSha256, - BooleanSupplier active) { + BooleanSupplier active) { String normalizedBackend = NativeDownloadConfig.normalizeBackend(backend); String normalizedPlatform = NativeDownloadConfig.normalizeKnownPlatform(platform); BooleanSupplier guard = active == null ? ALWAYS_ACTIVE : active; @@ -730,18 +721,18 @@ private static Optional findDirectNativeLibrary(String backend, String pla private static Path download(HttpClient http, String targetName, NativeDownloadConfig.DownloadSource source, int sourceIndex, int sourceCount, ProgressListener listener) throws Exception { - return download(http, targetName, source, sourceIndex, sourceCount, listener, ALWAYS_ACTIVE); + return download(http, targetName, source, sourceIndex, sourceCount, listener, ALWAYS_ACTIVE, false); } private static Path download(HttpClient http, String targetName, NativeDownloadConfig.DownloadSource source, int sourceIndex, int sourceCount, - ProgressListener listener, BooleanSupplier active) throws Exception { + ProgressListener listener, BooleanSupplier active, boolean proxyConfigured) throws Exception { checkActive(active); Files.createDirectories(DOWNLOAD_ROOT); Path target = DOWNLOAD_ROOT.resolve(targetName); Files.deleteIfExists(target); try { URI uri = URI.create(source.url.trim()); - if (!MediaAddressPolicy.isAllowed(uri.toString())) { + if (!MediaAddressPolicy.isAllowedForDownload(uri.toString(), proxyConfigured)) { throw new IOException("Download source address is not allowed"); } for (int redirect = 0; ; redirect++) { @@ -750,7 +741,10 @@ private static Path download(HttpClient http, String targetName, NativeDownloadC .header("User-Agent", "VideoPlayer/" + VideoPlayerMain.version) .GET() .build(); - HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofInputStream()); + HttpResponse response = awaitDownloadFuture( + http.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream()), + active + ); int status = response.statusCode(); if (status >= 300 && status < 400) { try (InputStream ignored = response.body()) { @@ -759,7 +753,7 @@ private static Path download(HttpClient http, String targetName, NativeDownloadC throw new IOException("Too many redirects while downloading native package"); } URI next = uri.resolve(location); - if (!MediaAddressPolicy.isAllowed(next.toString())) { + if (!MediaAddressPolicy.isAllowedForDownload(next.toString(), proxyConfigured)) { throw new IOException("Download redirect target is not allowed"); } uri = next; @@ -812,9 +806,43 @@ private static HttpClient httpClient(String proxy) { return HttpProxyConfig.parse(proxy).configure(builder).build(); } - private static int readWithIdleTimeout(InputStream input, byte[] buffer, BooleanSupplier active) throws IOException { - Future readTask = DOWNLOAD_READ_EXECUTOR.submit(() -> input.read(buffer)); + static T awaitDownloadFuture(CompletableFuture request, BooleanSupplier active) throws Exception { + ACTIVE_HTTP_REQUESTS.add(request); + try { + while (true) { + checkActive(active); + try { + return request.get(DOWNLOAD_CANCEL_POLL_MILLIS, TimeUnit.MILLISECONDS); + } catch (TimeoutException ignored) { + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + CancellationException cancelled = new CancellationException("Native package operation interrupted"); + cancelled.initCause(interrupted); + throw cancelled; + } catch (ExecutionException error) { + Throwable cause = error.getCause(); + if (cause instanceof Exception exception) throw exception; + if (cause instanceof Error fatal) throw fatal; + throw new IOException("Unable to receive download response", cause); + } + } + } finally { + ACTIVE_HTTP_REQUESTS.remove(request); + if (!request.isDone()) request.cancel(true); + } + } + + static int readWithIdleTimeout(InputStream input, byte[] buffer, BooleanSupplier active) throws IOException { + Future readTask = null; + ACTIVE_RESPONSE_BODIES.add(input); try { + synchronized (DOWNLOAD_READ_EXECUTOR_LOCK) { + checkActive(active); + if (downloadReadExecutor == null || downloadReadExecutor.isShutdown()) { + downloadReadExecutor = newDownloadReadExecutor(); + } + readTask = downloadReadExecutor.submit(() -> input.read(buffer)); + } long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(DOWNLOAD_READ_IDLE_TIMEOUT_MILLIS); while (true) { checkActive(active); @@ -837,7 +865,8 @@ private static int readWithIdleTimeout(InputStream input, byte[] buffer, Boolean } } } finally { - if (!readTask.isDone()) { + ACTIVE_RESPONSE_BODIES.remove(input); + if (readTask != null && !readTask.isDone()) { try { input.close(); } catch (IOException ignored) { @@ -847,6 +876,15 @@ private static int readWithIdleTimeout(InputStream input, byte[] buffer, Boolean } } + private static ExecutorService newDownloadReadExecutor() { + return Executors.newCachedThreadPool(task -> { + Thread thread = new Thread(task, "VideoPlayer-native-download-read"); + thread.setDaemon(true); + thread.setContextClassLoader(ClassLoader.getPlatformClassLoader()); + return thread; + }); + } + private static void verify(NativeDownloadConfig.DownloadSource source, Path zip) throws Exception { verify(source, zip, ALWAYS_ACTIVE); } diff --git a/src/main/java/com/github/squi2rel/vp/VideoPlayerMain.java b/src/main/java/com/github/squi2rel/vp/VideoPlayerMain.java index b5e32c8..093988f 100644 --- a/src/main/java/com/github/squi2rel/vp/VideoPlayerMain.java +++ b/src/main/java/com/github/squi2rel/vp/VideoPlayerMain.java @@ -16,9 +16,9 @@ import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.commands.Commands; +import net.minecraft.network.chat.Component; import net.minecraft.server.MinecraftServer; -import net.minecraft.server.command.CommandManager; -import net.minecraft.text.Text; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -57,12 +57,12 @@ public void onInitialize() { ServerWorldEvents.UNLOAD.register(DataHolder::unloadWorld); ServerTickEvents.START_SERVER_TICK.register(ignored -> DataHolder.update()); ServerPlayConnectionEvents.JOIN.register((e, p, s) -> DataHolder.playerJoin(e.player)); - ServerPlayConnectionEvents.DISCONNECT.register((e, s) -> DataHolder.playerLeave(e.player.getUuid())); + ServerPlayConnectionEvents.DISCONNECT.register((e, s) -> DataHolder.playerLeave(e.player.getUUID())); ServerPlayNetworking.registerGlobalReceiver(VideoPayload.ID, (p, c) -> { long receivedAt = System.currentTimeMillis(); byte[] copy = p.data().clone(); if (copy.length > VideoPackets.MAX_PAYLOAD_BYTES) { - c.player().networkHandler.disconnect(Text.of("VideoPlayer payload is too large")); + c.player().connection.disconnect(Component.nullToEmpty("VideoPlayer payload is too large")); return; } c.server().execute(() -> { @@ -70,14 +70,14 @@ public void onInitialize() { try { ServerPacketHandler.handle(c.player(), buf, receivedAt); } catch (Exception e) { - c.player().networkHandler.disconnect(Text.of(e.toString())); + c.player().connection.disconnect(Component.nullToEmpty(e.toString())); } finally { buf.release(); } }); }); - CommandRegistrationCallback.EVENT.register((d, c, e) -> d.register(CommandManager.literal("").then(CommandManager.argument("command", StringArgumentType.greedyString()).executes(s -> { - if (!s.getSource().isExecutedByPlayer()) return 0; + CommandRegistrationCallback.EVENT.register((d, c, e) -> d.register(Commands.literal("").then(Commands.argument("command", StringArgumentType.greedyString()).executes(s -> { + if (!s.getSource().isPlayer()) return 0; ServerPacketHandler.sendTo(s.getSource().getPlayer(), VideoPackets.execute(s.getArgument("command", String.class))); return 1; })))); diff --git a/src/main/java/com/github/squi2rel/vp/i18n/MinecraftTexts.java b/src/main/java/com/github/squi2rel/vp/i18n/MinecraftTexts.java index 15e9e0e..d3239d8 100644 --- a/src/main/java/com/github/squi2rel/vp/i18n/MinecraftTexts.java +++ b/src/main/java/com/github/squi2rel/vp/i18n/MinecraftTexts.java @@ -1,23 +1,23 @@ package com.github.squi2rel.vp.i18n; -import net.minecraft.text.Text; -import net.minecraft.text.MutableText; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.MutableComponent; public final class MinecraftTexts { private MinecraftTexts() { } - public static MutableText tr(String key, String fallback, Object... args) { + public static MutableComponent tr(String key, String fallback, Object... args) { return text(VpTranslation.of(key, fallback, args)); } - public static MutableText text(VpTranslation translation) { + public static MutableComponent text(VpTranslation translation) { if (translation == null || translation.isEmpty()) { - return Text.empty(); + return Component.empty(); } if (translation.isLiteral()) { - return Text.literal(translation.fallback()); + return Component.literal(translation.fallback()); } - return Text.translatableWithFallback(translation.key(), translation.fallback(), translation.argumentArray()); + return Component.translatableWithFallback(translation.key(), translation.fallback(), translation.argumentArray()); } } diff --git a/src/main/java/com/github/squi2rel/vp/network/ClientMessageBridge.java b/src/main/java/com/github/squi2rel/vp/network/ClientMessageBridge.java new file mode 100644 index 0000000..8bdbded --- /dev/null +++ b/src/main/java/com/github/squi2rel/vp/network/ClientMessageBridge.java @@ -0,0 +1,13 @@ +package com.github.squi2rel.vp.network; + +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; + +public final class ClientMessageBridge { + private ClientMessageBridge() { + } + + public static void sendOverlay(ServerPlayer player, Component message) { + player.displayClientMessage(message, true); + } +} diff --git a/src/main/java/com/github/squi2rel/vp/network/ServerPacketHandler.java b/src/main/java/com/github/squi2rel/vp/network/ServerPacketHandler.java index 152db4a..46aa24d 100644 --- a/src/main/java/com/github/squi2rel/vp/network/ServerPacketHandler.java +++ b/src/main/java/com/github/squi2rel/vp/network/ServerPacketHandler.java @@ -28,11 +28,11 @@ import com.github.squi2rel.vp.video.VideoSourceGraph; import io.netty.buffer.ByteBuf; import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; -import net.minecraft.command.DefaultPermissions; -import net.minecraft.server.PlayerManager; -import net.minecraft.server.network.ServerPlayerEntity; -import net.minecraft.text.Text; -import net.minecraft.util.Formatting; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.permissions.Permissions; +import net.minecraft.server.players.PlayerList; import org.joml.Vector3f; import java.util.ArrayList; @@ -44,18 +44,18 @@ public class ServerPacketHandler { private static final float EPSILON = 0.02f; - public static void handle(ServerPlayerEntity player, ByteBuf buf) { + public static void handle(ServerPlayer player, ByteBuf buf) { handle(player, buf, System.currentTimeMillis()); } - public static void handle(ServerPlayerEntity player, ByteBuf buf, long receivedAt) { + public static void handle(ServerPlayer player, ByteBuf buf, long receivedAt) { VideoPacketType type = VideoPackets.readType(buf); if (type == null) { - player.networkHandler.disconnect(Text.of("Unknown packet type")); + player.connection.disconnect(Component.nullToEmpty("Unknown packet type")); return; } if (type != VideoPacketType.CONFIG && type != VideoPacketType.HANDSHAKE_ACK - && !DataHolder.protocolActive(player.getUuid())) { + && !DataHolder.protocolActive(player.getUUID())) { return; } LOGGER.debug("server type: {}", type); @@ -63,7 +63,7 @@ public static void handle(ServerPlayerEntity player, ByteBuf buf, long receivedA case CONFIG -> { String remoteToken = ByteBufUtils.readString(buf, 16); if (!VideoProtocol.compatible(VideoPlayerMain.version, remoteToken)) { - if (DataHolder.rejectHandshake(player.getUuid())) { + if (DataHolder.rejectHandshake(player.getUUID())) { sendTo(player, VideoPackets.protocolReject(VideoPlayerMain.version)); reject(player, VpTranslation.of( "error.videoplayer.protocol_mismatch", @@ -73,16 +73,16 @@ public static void handle(ServerPlayerEntity player, ByteBuf buf, long receivedA } return; } - boolean protocolChanged = DataHolder.recordHandshakeToken(player.getUuid(), remoteToken); - String responseToken = DataHolder.handshakeToken(player.getUuid()); - VideoHandshakeState previous = DataHolder.handshakeState(player.getUuid()); + boolean protocolChanged = DataHolder.recordHandshakeToken(player.getUUID(), remoteToken); + String responseToken = DataHolder.handshakeToken(player.getUUID()); + VideoHandshakeState previous = DataHolder.handshakeState(player.getUUID()); if (previous == VideoHandshakeState.NEEDS_RESET) { - DataHolder.acceptHandshake(player.getUuid()); - long nonce = DataHolder.issueHandshakeNonce(player.getUuid()); + DataHolder.acceptHandshake(player.getUUID()); + long nonce = DataHolder.issueHandshakeNonce(player.getUUID()); sendTo(player, VideoPackets.resetClient(responseToken, DataHolder.config, nonce)); } else if (previous == VideoHandshakeState.RESET_SENT) { - long nonce = DataHolder.handshakeNonce(player.getUuid()); - if (nonce == 0L) nonce = DataHolder.issueHandshakeNonce(player.getUuid()); + long nonce = DataHolder.handshakeNonce(player.getUUID()); + if (nonce == 0L) nonce = DataHolder.issueHandshakeNonce(player.getUUID()); sendTo(player, VideoPackets.resetClient(responseToken, DataHolder.config, nonce)); } else if (previous == VideoHandshakeState.ACTIVE) { sendTo(player, VideoPackets.config(responseToken, DataHolder.config)); @@ -92,8 +92,8 @@ public static void handle(ServerPlayerEntity player, ByteBuf buf, long receivedA } case HANDSHAKE_ACK -> { long nonce = buf.readLong(); - if (DataHolder.acceptHandshakeAck(player.getUuid(), nonce)) { - sendTo(player, VideoPackets.config(DataHolder.handshakeToken(player.getUuid()), DataHolder.config)); + if (DataHolder.acceptHandshakeAck(player.getUUID(), nonce)) { + sendTo(player, VideoPackets.config(DataHolder.handshakeToken(player.getUUID()), DataHolder.config)); sendGlobalPermissions(player); } } @@ -103,7 +103,7 @@ public static void handle(ServerPlayerEntity player, ByteBuf buf, long receivedA VideoArea area = getArea(player, request.areaName()); VideoScreen screen = area == null ? null : area.getScreen(request.screenName()); if (screen != null) { - screen.acceptClientPlaybackResolution(player.getUuid(), request.generation(), + screen.acceptClientPlaybackResolution(player.getUUID(), request.generation(), request.reporterToken(), request.resolution(), request.durationMs()); } } @@ -197,7 +197,7 @@ url, new PlayerProviderSource(player, bilibiliLimit, youtubeLimit) long syncedProgress = listener.getProgress(); if (syncedProgress < 0) syncedProgress = progress; if (area.hasPlayer()) { - sendToPlayers(players(player.getEntityWorld().getServer().getPlayerManager(), area.playerSnapshot()), VideoPackets.sync(screen, syncedProgress)); + sendToPlayers(players(player.level().getServer().getPlayerList(), area.playerSnapshot()), VideoPackets.sync(screen, syncedProgress)); } requestOk(player, requestId); } @@ -206,8 +206,8 @@ url, new PlayerProviderSource(player, bilibiliLimit, youtubeLimit) Vector3f p1 = ByteBufUtils.readVec3(buf); Vector3f p2 = ByteBufUtils.readVec3(buf); String name = VideoPackets.readName(buf); - String dim = player.getEntityWorld().getRegistryKey().getValue().toString(); - DataHolder.loadWorld(player.getEntityWorld().getServer(), player.getEntityWorld()); + String dim = player.level().dimension().identifier().toString(); + DataHolder.loadWorld(player.level().getServer(), player.level()); if (!DataHolder.worldConfigValid(dim)) { requestError(player, requestId, VpTranslation.of("error.videoplayer.world_config_invalid", "The VideoPlayer configuration for this world is invalid and must be repaired before it can be modified")); return; @@ -229,7 +229,7 @@ url, new PlayerProviderSource(player, bilibiliLimit, youtubeLimit) VideoArea area = VideoArea.from(p1, p2, name, dim); area.initServer(); map.put(area.name, area); - VpTranslation confirmation = VpTranslation.of("message.videoplayer.area_created", "Created video area %s in world %s", area.name, player.getEntityWorld().getRegistryKey().getValue()); + VpTranslation confirmation = VpTranslation.of("message.videoplayer.area_created", "Created video area %s in world %s", area.name, player.level().dimension().identifier()); message(player, confirmation); requestOk(player, requestId, confirmation); sendAreaPermissions(player, area); @@ -239,7 +239,7 @@ url, new PlayerProviderSource(player, bilibiliLimit, youtubeLimit) VideoArea area = requireArea(player, requestId, VideoPackets.readName(buf)); if (area == null) return; if (!requirePermission(player, requestId, VideoPermissionAction.REMOVE_AREA, VideoPermissionContext.area(area))) return; - List receivers = List.of(); + List receivers = List.of(); byte[] data = null; HashMap map = DataHolder.areas.get(area.dim); VideoArea removed = map == null ? null : map.remove(area.name); @@ -249,11 +249,11 @@ url, new PlayerProviderSource(player, bilibiliLimit, youtubeLimit) } if (removed.hasPlayer()) { data = VideoPackets.removeArea(removed); - receivers = players(player.getEntityWorld().getServer().getPlayerManager(), removed.playerSnapshot()); + receivers = players(player.level().getServer().getPlayerList(), removed.playerSnapshot()); } removed.remove(); sendToPlayers(receivers, data); - VpTranslation confirmation = VpTranslation.of("message.videoplayer.area_removed", "Removed video area %s from world %s", area.name, player.getEntityWorld().getRegistryKey().getValue()); + VpTranslation confirmation = VpTranslation.of("message.videoplayer.area_removed", "Removed video area %s from world %s", area.name, player.level().dimension().identifier()); message(player, confirmation); requestOk(player, requestId, confirmation); } @@ -270,12 +270,12 @@ url, new PlayerProviderSource(player, bilibiliLimit, youtubeLimit) return; } screen.initServer(); - List receivers = List.of(); + List receivers = List.of(); byte[] data = null; area.addScreen(screen); if (area.hasPlayer()) { data = VideoPackets.createScreen(List.of(screen)); - receivers = players(player.getEntityWorld().getServer().getPlayerManager(), area.playerSnapshot()); + receivers = players(player.level().getServer().getPlayerList(), area.playerSnapshot()); } sendToPlayers(receivers, data); VpTranslation confirmation = VpTranslation.of("message.videoplayer.screen_created", "Created screen %s in video area %s", screen.name, area.name); @@ -296,7 +296,7 @@ url, new PlayerProviderSource(player, bilibiliLimit, youtubeLimit) return; } VideoScreen screen; - List receivers = List.of(); + List receivers = List.of(); byte[] data = null; screen = area.removeScreen(screenName); if (screen == null) { @@ -305,7 +305,7 @@ url, new PlayerProviderSource(player, bilibiliLimit, youtubeLimit) } if (screen != null && area.hasPlayer()) { data = VideoPackets.removeScreen(screen); - receivers = players(player.getEntityWorld().getServer().getPlayerManager(), area.playerSnapshot()); + receivers = players(player.level().getServer().getPlayerList(), area.playerSnapshot()); } sendToPlayers(receivers, data); if (screen != null) { @@ -334,15 +334,15 @@ url, new PlayerProviderSource(player, bilibiliLimit, youtubeLimit) return; } if (!requirePermission(player, requestId, VideoPermissionAction.VOTE_SKIP, VideoPermissionContext.screen(screen))) return; - screen.voteSkip(player.getUuid()); - Text s = MinecraftTexts.tr( + screen.voteSkip(player.getUUID()); + Component s = MinecraftTexts.tr( "message.videoplayer.skip_vote_broadcast", "Player %s voted to skip the video on %s. %s more players required", player.getName(), screen.name, screen.skipped() == 0 ? 0 : (int) (area.players() * screen.skipPercent - screen.skipped() + 1) ); - player.sendMessage(MinecraftTexts.tr("message.videoplayer.skip_voted", "Voted to skip this video").formatted(Formatting.GOLD)); - for (ServerPlayerEntity target : players(player.getEntityWorld().getServer().getPlayerManager(), area.playerSnapshot())) { - target.sendMessage(s); + player.sendSystemMessage(MinecraftTexts.tr("message.videoplayer.skip_voted", "Voted to skip this video").withStyle(ChatFormatting.GOLD)); + for (ServerPlayer target : players(player.level().getServer().getPlayerList(), area.playerSnapshot())) { + target.sendSystemMessage(s); } requestOk(player, requestId); } @@ -366,7 +366,7 @@ url, new PlayerProviderSource(player, bilibiliLimit, youtubeLimit) int requestId = buf.readInt(); String areaName = VideoPackets.readName(buf); String screenName = VideoPackets.readName(buf); - boolean mutations = DataHolder.supportsIdlePlayMutations(player.getUuid()); + boolean mutations = DataHolder.supportsIdlePlayMutations(player.getUUID()); IdlePlayMutation mutation = mutations ? VideoPackets.readIdlePlayMutation(buf) : null; VideoPackets.LegacyIdlePlayConfig legacy = mutations ? null : VideoPackets.readLegacyIdlePlayConfig(buf); VideoArea area = requireArea(player, requestId, areaName); @@ -383,9 +383,9 @@ url, new PlayerProviderSource(player, bilibiliLimit, youtubeLimit) } if (legacy != null) { screen.replaceLegacyIdlePlayConfig( - legacy.urls(), legacy.random(), player.getUuid(), player.getName().getString() + legacy.urls(), legacy.random(), player.getUUID(), player.getName().getString() ); - } else if (!screen.applyIdlePlayMutation(mutation, player.getUuid(), player.getName().getString())) { + } else if (!screen.applyIdlePlayMutation(mutation, player.getUUID(), player.getName().getString())) { requestError(player, requestId, VpTranslation.of("error.videoplayer.idle_play_mutation_failed", "Unable to update IdlePlay entry")); return; } @@ -417,7 +417,7 @@ url, new PlayerProviderSource(player, bilibiliLimit, youtubeLimit) screen.u2 = u2; screen.v2 = v2; if (area.hasPlayer()) { - sendToPlayers(players(player.getEntityWorld().getServer().getPlayerManager(), area.playerSnapshot()), VideoPackets.setUv(screen, screen.u1, screen.v1, screen.u2, screen.v2)); + sendToPlayers(players(player.level().getServer().getPlayerList(), area.playerSnapshot()), VideoPackets.setUv(screen, screen.u1, screen.v1, screen.u2, screen.v2)); } requestOk(player, requestId); } @@ -479,7 +479,7 @@ url, new PlayerProviderSource(player, bilibiliLimit, youtubeLimit) return; } if (area.hasPlayer()) { - sendToPlayers(players(player.getEntityWorld().getServer().getPlayerManager(), area.playerSnapshot()), VideoPackets.removeMetadata(screen, key)); + sendToPlayers(players(player.level().getServer().getPlayerList(), area.playerSnapshot()), VideoPackets.removeMetadata(screen, key)); } requestOk(player, requestId); return; @@ -490,7 +490,7 @@ url, new PlayerProviderSource(player, bilibiliLimit, youtubeLimit) } screen.metadata.set(key, value); if (area.hasPlayer()) { - sendToPlayers(players(player.getEntityWorld().getServer().getPlayerManager(), area.playerSnapshot()), VideoPackets.setMetadata(screen, key, value)); + sendToPlayers(players(player.level().getServer().getPlayerList(), area.playerSnapshot()), VideoPackets.setMetadata(screen, key, value)); } requestOk(player, requestId); } @@ -513,7 +513,7 @@ url, new PlayerProviderSource(player, bilibiliLimit, youtubeLimit) screen.scaleX = scaleX; screen.scaleY = scaleY; if (area.hasPlayer()) { - sendToPlayers(players(player.getEntityWorld().getServer().getPlayerManager(), area.playerSnapshot()), VideoPackets.setScale(screen, fill, scaleX, scaleY)); + sendToPlayers(players(player.level().getServer().getPlayerList(), area.playerSnapshot()), VideoPackets.setScale(screen, fill, scaleX, scaleY)); } requestOk(player, requestId); } @@ -572,20 +572,20 @@ url, new PlayerProviderSource(player, bilibiliLimit, youtubeLimit) screen.source = source == null ? "" : source; screen.copyDisplayConfigFrom(displayConfig); if (area.hasPlayer()) { - sendToPlayers(players(player.getEntityWorld().getServer().getPlayerManager(), area.playerSnapshot()), VideoPackets.updateScreen(screen, screen.vertices, screen.source)); + sendToPlayers(players(player.level().getServer().getPlayerList(), area.playerSnapshot()), VideoPackets.updateScreen(screen, screen.vertices, screen.source)); } VpTranslation confirmation = VpTranslation.of("message.videoplayer.screen_updated", "Updated screen %s", screen.name); message(player, confirmation); requestOk(player, requestId, confirmation); } - default -> player.networkHandler.disconnect(Text.of("Unknown packet type: " + type)); + default -> player.connection.disconnect(Component.nullToEmpty("Unknown packet type: " + type)); } if (buf.readableBytes() > 0) { - player.networkHandler.disconnect(Text.of("Illegal packet! Remaining: " + buf.readableBytes())); + player.connection.disconnect(Component.nullToEmpty("Illegal packet! Remaining: " + buf.readableBytes())); } } - private static boolean requirePermission(ServerPlayerEntity player, int requestId, VideoPermissionAction action, VideoPermissionContext context) { + private static boolean requirePermission(ServerPlayer player, int requestId, VideoPermissionAction action, VideoPermissionContext context) { VideoPermissionPlayer permissionPlayer = VideoPermissions.player(player); if (VideoPermissions.allowed(permissionPlayer, action, context)) return true; sendPermissionCache(player, context); @@ -594,25 +594,25 @@ private static boolean requirePermission(ServerPlayerEntity player, int requestI return false; } - private static void requestOk(ServerPlayerEntity player, int requestId) { + private static void requestOk(ServerPlayer player, int requestId) { sendTo(player, VideoPackets.requestResult(requestId, RequestResultStatus.OK, VpTranslation.EMPTY)); } - private static void requestOk(ServerPlayerEntity player, int requestId, VpTranslation message) { + private static void requestOk(ServerPlayer player, int requestId, VpTranslation message) { sendTo(player, VideoPackets.requestResult(requestId, RequestResultStatus.OK, message)); } - private static void requestError(ServerPlayerEntity player, int requestId, VpTranslation message) { + private static void requestError(ServerPlayer player, int requestId, VpTranslation message) { sendTo(player, VideoPackets.requestResult(requestId, RequestResultStatus.ERROR, message)); } - public static void sendGlobalPermissions(ServerPlayerEntity player) { + public static void sendGlobalPermissions(ServerPlayer player) { if (player == null) return; - String dim = player.getEntityWorld().getRegistryKey().getValue().toString(); + String dim = player.level().dimension().identifier().toString(); sendPermissionCache(player, VideoPermissionContext.global(dim)); } - public static void sendAreaPermissions(ServerPlayerEntity player, VideoArea area) { + public static void sendAreaPermissions(ServerPlayer player, VideoArea area) { if (player == null || area == null) return; sendPermissionCache(player, VideoPermissionContext.area(area)); for (VideoScreen screen : area.screens) { @@ -620,47 +620,47 @@ public static void sendAreaPermissions(ServerPlayerEntity player, VideoArea area } } - public static void refreshPermissions(ServerPlayerEntity player) { + public static void refreshPermissions(ServerPlayer player) { if (player == null) return; sendGlobalPermissions(player); - String dim = player.getEntityWorld().getRegistryKey().getValue().toString(); + String dim = player.level().dimension().identifier().toString(); HashMap map = DataHolder.areas.get(dim); if (map == null) return; for (VideoArea area : map.values()) { - if (area.containsPlayer(player.getUuid())) { + if (area.containsPlayer(player.getUUID())) { sendAreaPermissions(player, area); } } } - public static void refreshPermissions(ServerPlayerEntity player, VideoArea area) { + public static void refreshPermissions(ServerPlayer player, VideoArea area) { sendAreaPermissions(player, area); } public static void refreshPermissions(VideoArea area) { if (area == null || !area.hasPlayer() || DataHolder.server == null) return; - PlayerManager playerManager = DataHolder.server.getPlayerManager(); + PlayerList playerManager = DataHolder.server.getPlayerList(); for (java.util.UUID uuid : area.playerSnapshot()) { sendAreaPermissions(playerManager.getPlayer(uuid), area); } } - private static void sendPermissionCache(ServerPlayerEntity player, VideoPermissionContext context) { + private static void sendPermissionCache(ServerPlayer player, VideoPermissionContext context) { if (player == null) return; VideoPermissionContext safeContext = context == null ? VideoPermissionContext.global(null) : context; long mask = VideoPermissions.mask(VideoPermissions.player(player), safeContext); sendTo(player, VideoPackets.permissions(safeContext.areaName(), safeContext.screenName(), mask)); } - private static VideoArea getArea(ServerPlayerEntity player, String name) { - String dim = player.getEntityWorld().getRegistryKey().getValue().toString(); - DataHolder.loadWorld(player.getEntityWorld().getServer(), player.getEntityWorld()); + private static VideoArea getArea(ServerPlayer player, String name) { + String dim = player.level().dimension().identifier().toString(); + DataHolder.loadWorld(player.level().getServer(), player.level()); HashMap map = DataHolder.areas.get(dim); VideoArea area = map == null ? null : map.get(name); - return area != null && area.containsPlayer(player.getUuid()) ? area : null; + return area != null && area.containsPlayer(player.getUUID()) ? area : null; } - private static VideoArea requireArea(ServerPlayerEntity player, int requestId, String name) { + private static VideoArea requireArea(ServerPlayer player, int requestId, String name) { VideoArea area = getArea(player, name); if (area == null) { requestError(player, requestId, VpTranslation.of("error.videoplayer.area_not_found_or_not_inside", "Video area was not found, or you are not inside it")); @@ -668,7 +668,7 @@ private static VideoArea requireArea(ServerPlayerEntity player, int requestId, S return area; } - private static VideoScreen requireScreen(ServerPlayerEntity player, int requestId, VideoArea area, String name) { + private static VideoScreen requireScreen(ServerPlayer player, int requestId, VideoArea area, String name) { VideoScreen screen = area == null ? null : area.getScreen(name); if (screen == null) { requestError(player, requestId, VpTranslation.of("error.videoplayer.screen_not_found", "Screen not found")); @@ -676,7 +676,7 @@ private static VideoScreen requireScreen(ServerPlayerEntity player, int requestI return screen; } - private static boolean validName(ServerPlayerEntity player, String name, String type) { + private static boolean validName(ServerPlayer player, String name, String type) { if (name == null || name.isBlank()) { reject(player, VpTranslation.of("error.videoplayer.name_empty", "%s name must not be empty", type)); return false; @@ -688,7 +688,7 @@ private static boolean validName(ServerPlayerEntity player, String name, String return true; } - private static boolean validAreaBounds(ServerPlayerEntity player, Vector3f p1, Vector3f p2) { + private static boolean validAreaBounds(ServerPlayer player, Vector3f p1, Vector3f p2) { if (!validVector(p1) || !validVector(p2)) { reject(player, VpTranslation.of("error.videoplayer.area_coordinates_invalid", "Area coordinates are invalid")); return false; @@ -706,7 +706,7 @@ private static boolean validAreaBounds(ServerPlayerEntity player, Vector3f p1, V return true; } - private static boolean validScreen(ServerPlayerEntity player, VideoArea area, VideoScreen screen) { + private static boolean validScreen(ServerPlayer player, VideoArea area, VideoScreen screen) { if (area.screens.size() >= VideoArea.MAX_SCREENS) { reject(player, VpTranslation.of("error.videoplayer.screen_limit", "Video area can contain at most %s screens", VideoArea.MAX_SCREENS)); return false; @@ -726,7 +726,7 @@ private static boolean validScreen(ServerPlayerEntity player, VideoArea area, Vi return validScreenShape(player, area, screen, screen.vertices); } - private static boolean validScreenUpdate(ServerPlayerEntity player, VideoArea area, VideoScreen screen, List vertices, String source, VideoScreen displayConfig) { + private static boolean validScreenUpdate(ServerPlayer player, VideoArea area, VideoScreen screen, List vertices, String source, VideoScreen displayConfig) { if (!validScreenSource(player, area, screen.name, source == null ? "" : source)) return false; displayConfig.ensureValidState(); if (!displayConfig.hasValidDisplayConfig()) { @@ -736,7 +736,7 @@ private static boolean validScreenUpdate(ServerPlayerEntity player, VideoArea ar return validScreenShape(player, area, displayConfig, vertices); } - private static boolean validMetadata(ServerPlayerEntity player, VideoScreen screen, String key, MetaValue value) { + private static boolean validMetadata(ServerPlayer player, VideoScreen screen, String key, MetaValue value) { try { ScreenMetadata.validateKey(key); value.validateValue(); @@ -751,7 +751,7 @@ private static boolean validMetadata(ServerPlayerEntity player, VideoScreen scre } } - private static boolean canModifyMetadata(ServerPlayerEntity player, String key) { + private static boolean canModifyMetadata(ServerPlayer player, String key) { try { ScreenMetadata.validateKey(key); } catch (IllegalArgumentException e) { @@ -775,8 +775,8 @@ private static boolean isUserMetadataOption(String key) { }; } - private static boolean isAdmin(ServerPlayerEntity player) { - return player.getCommandSource().getPermissions().hasPermission(DefaultPermissions.GAMEMASTERS); + private static boolean isAdmin(ServerPlayer player) { + return player.createCommandSourceStack().permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER); } private static void validateBuiltInMetadata(VideoScreen screen, String key, MetaValue value) { @@ -819,7 +819,7 @@ private static void requireType(String key, MetaValue value, MetaType type) { } } - private static boolean validScreenSource(ServerPlayerEntity player, VideoArea area, String screenName, String source) { + private static boolean validScreenSource(ServerPlayer player, VideoArea area, String screenName, String source) { if (source == null || source.isEmpty()) return true; if (source.equals(screenName)) { reject(player, VpTranslation.of("error.videoplayer.source_screen_self", "Source Screen cannot point to itself")); @@ -836,7 +836,7 @@ private static boolean validScreenSource(ServerPlayerEntity player, VideoArea ar return true; } - private static boolean validScreenVertices(ServerPlayerEntity player, VideoArea area, List vertices) { + private static boolean validScreenVertices(ServerPlayer player, VideoArea area, List vertices) { if (vertices == null || vertices.size() < ScreenGeometry.MIN_VERTICES || vertices.size() > ScreenGeometry.MAX_VERTICES) { reject(player, VpTranslation.of("error.videoplayer.screen_vertex_count", "Screen vertex count must be between %s and %s", ScreenGeometry.MIN_VERTICES, ScreenGeometry.MAX_VERTICES)); return false; @@ -860,14 +860,14 @@ private static boolean validScreenVertices(ServerPlayerEntity player, VideoArea return true; } - private static boolean validScreenShape(ServerPlayerEntity player, VideoArea area, VideoScreen screen, List vertices) { + private static boolean validScreenShape(ServerPlayer player, VideoArea area, VideoScreen screen, List vertices) { if (screen.surface == ScreenSurface.SPHERE_360) { return validSphere(player, area, screen); } return validScreenVertices(player, area, vertices); } - private static boolean validSphere(ServerPlayerEntity player, VideoArea area, VideoScreen screen) { + private static boolean validSphere(ServerPlayer player, VideoArea area, VideoScreen screen) { if (!screen.spherePreset) { reject(player, VpTranslation.of("error.videoplayer.sphere_preset_required", "Define 360 parameters first")); return false; @@ -931,15 +931,15 @@ private static String errorMessage(Throwable error) { return error == null || error.getMessage() == null ? "" : error.getMessage(); } - private static void reject(ServerPlayerEntity player, VpTranslation message) { - player.sendMessage(MinecraftTexts.text(message).formatted(Formatting.RED)); + private static void reject(ServerPlayer player, VpTranslation message) { + player.sendSystemMessage(MinecraftTexts.text(message).withStyle(ChatFormatting.RED)); } - private static void message(ServerPlayerEntity player, VpTranslation message) { - player.sendMessage(MinecraftTexts.text(message).formatted(Formatting.GREEN)); + private static void message(ServerPlayer player, VpTranslation message) { + player.sendSystemMessage(MinecraftTexts.text(message).withStyle(ChatFormatting.GREEN)); } - public static void sendTo(ServerPlayerEntity player, byte[] bytes) { + public static void sendTo(ServerPlayer player, byte[] bytes) { if (player == null || bytes == null) return; if (bytes.length > VideoPackets.MAX_PAYLOAD_BYTES) { LOGGER.warn("Dropped oversized VideoPlayer payload: {} bytes", bytes.length); @@ -948,10 +948,10 @@ public static void sendTo(ServerPlayerEntity player, byte[] bytes) { ServerPlayNetworking.send(player, new VideoPayload(bytes)); } - private static List players(PlayerManager pm, List uuids) { - ArrayList players = new ArrayList<>(uuids.size()); + private static List players(PlayerList pm, List uuids) { + ArrayList players = new ArrayList<>(uuids.size()); for (var uuid : uuids) { - ServerPlayerEntity target = pm.getPlayer(uuid); + ServerPlayer target = pm.getPlayer(uuid); if (target != null) { players.add(target); } @@ -959,9 +959,9 @@ private static List players(PlayerManager pm, List players, byte[] bytes) { + private static void sendToPlayers(List players, byte[] bytes) { if (bytes == null) return; - for (ServerPlayerEntity target : players) { + for (ServerPlayer target : players) { sendTo(target, bytes); } } diff --git a/src/main/java/com/github/squi2rel/vp/network/VideoPackets.java b/src/main/java/com/github/squi2rel/vp/network/VideoPackets.java index 2e7bd08..88463c7 100644 --- a/src/main/java/com/github/squi2rel/vp/network/VideoPackets.java +++ b/src/main/java/com/github/squi2rel/vp/network/VideoPackets.java @@ -394,6 +394,12 @@ public static byte[] config(String version, ServerConfig config) { return config(VideoPacketType.CONFIG, version, config); } + public static byte[] clientConfig(String version) { + ByteBuf buf = create(VideoPacketType.CONFIG); + writeString(buf, VideoProtocol.handshakeToken(version)); + return toByteArray(buf); + } + public static byte[] resetClient(String version, ServerConfig config, long nonce) { ByteBuf buf = create(VideoPacketType.RESET_CLIENT); writeString(buf, version); diff --git a/src/main/java/com/github/squi2rel/vp/network/VideoPayload.java b/src/main/java/com/github/squi2rel/vp/network/VideoPayload.java index bd4a097..ea89658 100644 --- a/src/main/java/com/github/squi2rel/vp/network/VideoPayload.java +++ b/src/main/java/com/github/squi2rel/vp/network/VideoPayload.java @@ -2,15 +2,15 @@ import com.github.squi2rel.vp.VideoPlayerMain; import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry; -import net.minecraft.network.PacketByteBuf; -import net.minecraft.network.codec.PacketCodec; -import net.minecraft.network.packet.CustomPayload; -import net.minecraft.util.Identifier; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; -public record VideoPayload(byte[] data) implements CustomPayload { - public static final Identifier VIDEO_PAYLOAD_ID = Identifier.of(VideoPlayerMain.MOD_ID, "video"); - public static final CustomPayload.Id ID = new CustomPayload.Id<>(VIDEO_PAYLOAD_ID); - public static final PacketCodec CODEC = PacketCodec.of((p, buf) -> buf.writeBytes(p.data), buf -> { +public record VideoPayload(byte[] data) implements CustomPacketPayload { + public static final Identifier VIDEO_PAYLOAD_ID = Identifier.fromNamespaceAndPath(VideoPlayerMain.MOD_ID, "video"); + public static final CustomPacketPayload.Type ID = new CustomPacketPayload.Type<>(VIDEO_PAYLOAD_ID); + public static final StreamCodec CODEC = StreamCodec.ofMember((p, buf) -> buf.writeBytes(p.data), buf -> { if (buf.readableBytes() > VideoPackets.MAX_PAYLOAD_BYTES) { throw new IllegalStateException("VideoPlayer payload exceeds " + VideoPackets.MAX_PAYLOAD_BYTES + " bytes"); } @@ -20,7 +20,7 @@ public record VideoPayload(byte[] data) implements CustomPayload { }); @Override - public Id getId() { + public Type type() { return ID; } diff --git a/src/main/java/com/github/squi2rel/vp/network/VideoProtocol.java b/src/main/java/com/github/squi2rel/vp/network/VideoProtocol.java index a953ad9..a34c515 100644 --- a/src/main/java/com/github/squi2rel/vp/network/VideoProtocol.java +++ b/src/main/java/com/github/squi2rel/vp/network/VideoProtocol.java @@ -1,7 +1,10 @@ package com.github.squi2rel.vp.network; +import java.nio.charset.StandardCharsets; + public final class VideoProtocol { public static final String WIRE_REVISION = "vp5"; + public static final int MAX_TOKEN_BYTES = 16; private static final int LEGACY_WIRE_REVISION = 2; private static final int REPORTING_WIRE_REVISION = 4; private static final int IDLE_PLAY_MUTATION_WIRE_REVISION = 5; @@ -10,7 +13,16 @@ private VideoProtocol() { } public static String token(String version) { - return safe(version) + "|" + WIRE_REVISION; + String token = safe(version) + "|" + WIRE_REVISION; + int length = token.getBytes(StandardCharsets.UTF_8).length; + if (length > MAX_TOKEN_BYTES) { + throw new IllegalArgumentException("VideoPlayer protocol token exceeds " + MAX_TOKEN_BYTES + " UTF-8 bytes"); + } + return token; + } + + public static String handshakeToken(String version) { + return "2.0.3".equals(releaseVersion(version)) ? token("2.0.2") : token(version); } public static String legacyToken() { @@ -69,7 +81,7 @@ private static boolean supportedWireRevision(String token) { } private static boolean optionalUpdateRelease(String release) { - return "2.0.1".equals(release) || "2.0.2".equals(release); + return "2.0.1".equals(release) || "2.0.2".equals(release) || "2.0.3".equals(release); } private static String releaseVersion(String token) { diff --git a/src/main/java/com/github/squi2rel/vp/permission/VideoPermissions.java b/src/main/java/com/github/squi2rel/vp/permission/VideoPermissions.java index df01597..99c43e7 100644 --- a/src/main/java/com/github/squi2rel/vp/permission/VideoPermissions.java +++ b/src/main/java/com/github/squi2rel/vp/permission/VideoPermissions.java @@ -1,11 +1,9 @@ package com.github.squi2rel.vp.permission; -import net.minecraft.command.DefaultPermissions; -import net.minecraft.server.network.ServerPlayerEntity; - import java.util.EnumSet; import java.util.Objects; import java.util.Set; +import net.minecraft.server.permissions.Permissions; public final class VideoPermissions { private static final Set PUBLIC_ACTIONS = EnumSet.complementOf(EnumSet.of( @@ -53,14 +51,14 @@ public static long mask(VideoPermissionPlayer player, VideoPermissionContext con return mask; } - public static VideoPermissionPlayer player(ServerPlayerEntity player) { + public static VideoPermissionPlayer player(net.minecraft.server.level.ServerPlayer player) { return new ServerPlayer(player); } - private record ServerPlayer(ServerPlayerEntity player) implements VideoPermissionPlayer { + private record ServerPlayer(net.minecraft.server.level.ServerPlayer player) implements VideoPermissionPlayer { @Override public java.util.UUID uuid() { - return player.getUuid(); + return player.getUUID(); } @Override @@ -70,7 +68,7 @@ public String name() { @Override public boolean opOrGameMaster() { - return player.getCommandSource().getPermissions().hasPermission(DefaultPermissions.GAMEMASTERS); + return player.createCommandSourceStack().permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER); } } } diff --git a/src/main/java/com/github/squi2rel/vp/provider/MediaAddressPolicy.java b/src/main/java/com/github/squi2rel/vp/provider/MediaAddressPolicy.java index 5045f87..07afe5b 100644 --- a/src/main/java/com/github/squi2rel/vp/provider/MediaAddressPolicy.java +++ b/src/main/java/com/github/squi2rel/vp/provider/MediaAddressPolicy.java @@ -34,6 +34,36 @@ public static boolean isAllowed(String raw) { return isAllowed(raw, InetAddress::getAllByName); } + public static boolean isAllowedForDownload(String raw, boolean proxyConfigured) { + return isAllowedForDownload(raw, proxyConfigured, InetAddress::getAllByName); + } + + static boolean isAllowedForDownload(String raw, boolean proxyConfigured, HostResolver resolver) { + if (!proxyConfigured) return isAllowed(raw, resolver); + if (!isSyntacticallyAllowed(raw)) return false; + URI uri; + try { + uri = URI.create(raw.trim()); + } catch (IllegalArgumentException ignored) { + return false; + } + try { + String host = uri.getHost(); + boolean ipLiteral = isIpLiteral(host); + InetAddress[] addresses = resolver.resolve(host); + if (addresses.length == 0) return false; + for (InetAddress address : addresses) { + if (!ipLiteral && isProxySyntheticAddress(address)) continue; + if (isBlocked(address)) return false; + } + return true; + } catch (UnknownHostException ignored) { + return false; + } catch (RuntimeException ignored) { + return false; + } + } + static boolean isAllowed(String raw, HostResolver resolver) { if (!isSyntacticallyAllowed(raw)) return false; URI uri; @@ -56,6 +86,24 @@ static boolean isAllowed(String raw, HostResolver resolver) { } } + private static boolean isIpLiteral(String host) { + if (host == null || host.isEmpty()) return false; + if (host.indexOf(':') >= 0) return true; + for (int i = 0; i < host.length(); i++) { + char current = host.charAt(i); + if ((current < '0' || current > '9') && current != '.') return false; + } + return true; + } + + private static boolean isProxySyntheticAddress(InetAddress address) { + if (!(address instanceof Inet4Address)) return false; + byte[] bytes = address.getAddress(); + int first = bytes[0] & 0xff; + int second = bytes[1] & 0xff; + return first == 198 && (second == 18 || second == 19); + } + @FunctionalInterface interface HostResolver { InetAddress[] resolve(String host) throws UnknownHostException; diff --git a/src/main/java/com/github/squi2rel/vp/provider/PlayerProviderSource.java b/src/main/java/com/github/squi2rel/vp/provider/PlayerProviderSource.java index a903e6a..8772e81 100644 --- a/src/main/java/com/github/squi2rel/vp/provider/PlayerProviderSource.java +++ b/src/main/java/com/github/squi2rel/vp/provider/PlayerProviderSource.java @@ -4,9 +4,8 @@ import com.github.squi2rel.vp.i18n.VpTranslation; import com.github.squi2rel.vp.provider.bilibili.BiliQuality; import com.github.squi2rel.vp.provider.youtube.YouTubeQuality; -import net.minecraft.server.network.ServerPlayerEntity; - import java.util.UUID; +import net.minecraft.server.level.ServerPlayer; public class PlayerProviderSource implements IProviderSource { private final UUID playerUuid; @@ -15,12 +14,12 @@ public class PlayerProviderSource implements IProviderSource { private final int bilibiliQualityLimit; private final int youtubeHeightLimit; - public PlayerProviderSource(ServerPlayerEntity entity) { + public PlayerProviderSource(ServerPlayer entity) { this(entity, BiliQuality.SERVER_LISTENER_QN, YouTubeQuality.AUTO); } - public PlayerProviderSource(ServerPlayerEntity entity, int bilibiliQualityLimit, int youtubeHeightLimit) { - playerUuid = entity.getUuid(); + public PlayerProviderSource(ServerPlayer entity, int bilibiliQualityLimit, int youtubeHeightLimit) { + playerUuid = entity.getUUID(); name = entity.getGameProfile().name(); lifecycleEpoch = DataHolder.lifecycleEpoch(); this.bilibiliQualityLimit = BiliQuality.normalizeScreenLimit(bilibiliQualityLimit); diff --git a/src/main/java/com/github/squi2rel/vp/render/ExternalTextureRegistry.java b/src/main/java/com/github/squi2rel/vp/render/ExternalTextureRegistry.java new file mode 100644 index 0000000..c078a43 --- /dev/null +++ b/src/main/java/com/github/squi2rel/vp/render/ExternalTextureRegistry.java @@ -0,0 +1,42 @@ +package com.github.squi2rel.vp.render; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public final class ExternalTextureRegistry { + private final Map active = new LinkedHashMap<>(); + private long nextGeneration; + + public synchronized Acquisition acquire(int rawTextureId) { + Registration existing = active.get(rawTextureId); + if (existing != null) return new Acquisition(existing, false); + Registration created = new Registration(rawTextureId, ++nextGeneration); + active.put(rawTextureId, created); + return new Acquisition(created, true); + } + + public synchronized Optional release(int rawTextureId) { + return Optional.ofNullable(active.remove(rawTextureId)); + } + + public synchronized List clear() { + List registrations = List.copyOf(active.values()); + active.clear(); + return registrations; + } + + public synchronized int size() { + return active.size(); + } + + public record Acquisition(Registration registration, boolean created) { + } + + public record Registration(int rawTextureId, long generation) { + public String identifierPath() { + return "external_texture/" + rawTextureId + "/" + generation; + } + } +} diff --git a/src/main/java/com/github/squi2rel/vp/video/IVideoListener.java b/src/main/java/com/github/squi2rel/vp/video/IVideoListener.java index 2d5fdf9..94d87f9 100644 --- a/src/main/java/com/github/squi2rel/vp/video/IVideoListener.java +++ b/src/main/java/com/github/squi2rel/vp/video/IVideoListener.java @@ -18,6 +18,14 @@ default void setProgress(long progress) { void timeout(Runnable timeout); + default AudioLevelSnapshot audioLevel() { + return AudioLevelSnapshot.unsupported(); + } + + default VideoColorSnapshot videoColor() { + return VideoColorSnapshot.unsupported(); + } + void listen(); void cancel(); diff --git a/src/main/java/com/github/squi2rel/vp/video/ListenerShutdownMonitor.java b/src/main/java/com/github/squi2rel/vp/video/ListenerShutdownMonitor.java new file mode 100644 index 0000000..7374810 --- /dev/null +++ b/src/main/java/com/github/squi2rel/vp/video/ListenerShutdownMonitor.java @@ -0,0 +1,43 @@ +package com.github.squi2rel.vp.video; + +import java.util.List; +import java.util.function.LongConsumer; +import java.util.function.Predicate; +import java.util.concurrent.locks.LockSupport; + +final class ListenerShutdownMonitor { + private static final long POLL_NANOS = 50_000_000L; + + private ListenerShutdownMonitor() { + } + + static void start(String threadName, List listeners, Predicate active, + long timeoutMs, Runnable completed, LongConsumer timedOut) { + List snapshot = List.copyOf(listeners); + Thread monitor = new Thread( + () -> monitor(snapshot, active, Math.max(1L, timeoutMs), completed, timedOut), + threadName + ); + monitor.setDaemon(true); + monitor.start(); + } + + private static void monitor(List listeners, Predicate active, + long timeoutMs, Runnable completed, LongConsumer timedOut) { + long deadline = System.nanoTime() + timeoutMs * 1_000_000L; + boolean reported = false; + while (true) { + long remaining = listeners.stream().filter(active).count(); + if (remaining == 0L) { + completed.run(); + return; + } + if (!reported && System.nanoTime() >= deadline) { + reported = true; + timedOut.accept(remaining); + } + LockSupport.parkNanos(POLL_NANOS); + if (Thread.currentThread().isInterrupted()) return; + } + } +} diff --git a/src/main/java/com/github/squi2rel/vp/video/MpvFrameColorParser.java b/src/main/java/com/github/squi2rel/vp/video/MpvFrameColorParser.java new file mode 100644 index 0000000..ec48365 --- /dev/null +++ b/src/main/java/com/github/squi2rel/vp/video/MpvFrameColorParser.java @@ -0,0 +1,82 @@ +package com.github.squi2rel.vp.video; + +import java.util.Locale; + +public final class MpvFrameColorParser { + private MpvFrameColorParser() { + } + + public static VideoColorSnapshot parse(String metadata, String colorMatrix, String colorLevels, long sampledAtMs) { + if (metadata == null || metadata.isBlank()) return VideoColorSnapshot.waiting(); + Double y = null; + Double u = null; + Double v = null; + String normalized = metadata.replace("\\n", "\n"); + for (String line : normalized.split("[\\r\\n,]+")) { + int separator = line.indexOf('='); + if (separator < 0) separator = line.indexOf(':'); + if (separator <= 0) continue; + String key = cleanToken(line.substring(0, separator)).toLowerCase(Locale.ROOT); + Double value = parseNumber(cleanToken(line.substring(separator + 1))); + if (value == null) continue; + if (key.endsWith("signalstats.yavg")) y = value; + if (key.endsWith("signalstats.uavg")) u = value; + if (key.endsWith("signalstats.vavg")) v = value; + } + if (y == null || u == null || v == null) return VideoColorSnapshot.waiting(); + boolean full = "full".equalsIgnoreCase(cleanToken(colorLevels)); + double normalizedY = full ? y / 255.0 : (y - 16.0) / 219.0; + double normalizedU = (u - 128.0) / (full ? 255.0 : 224.0); + double normalizedV = (v - 128.0) / (full ? 255.0 : 224.0); + Matrix matrix = Matrix.from(colorMatrix); + double red = normalizedY + matrix.redV * normalizedV; + double green = normalizedY + matrix.greenU * normalizedU + matrix.greenV * normalizedV; + double blue = normalizedY + matrix.blueU * normalizedU; + int r = channel(red); + int g = channel(green); + int b = channel(blue); + int rgb = r << 16 | g << 8 | b; + float luminance = (float) Math.clamp(0.2126 * red + 0.7152 * green + 0.0722 * blue, 0.0, 1.0); + return VideoColorSnapshot.available(rgb, luminance, sampledAtMs); + } + + private static int channel(double value) { + return Math.clamp((int) Math.round(value * 255.0), 0, 255); + } + + private static Double parseNumber(String value) { + if (value == null || value.isBlank()) return null; + try { + double parsed = Double.parseDouble(value); + return Double.isFinite(parsed) ? parsed : null; + } catch (NumberFormatException ignored) { + return null; + } + } + + private static String cleanToken(String value) { + String clean = value == null ? "" : value.trim(); + int start = 0; + int end = clean.length(); + while (start < end && isWrapper(clean.charAt(start))) start++; + while (end > start && isWrapper(clean.charAt(end - 1))) end--; + return clean.substring(start, end); + } + + private static boolean isWrapper(char value) { + return value == '"' || value == '\'' || value == '{' || value == '}' || value == '[' || value == ']'; + } + + private record Matrix(double redV, double greenU, double greenV, double blueU) { + private static Matrix from(String value) { + String normalized = value == null ? "" : value.trim().toLowerCase(Locale.ROOT); + if (normalized.contains("601") || normalized.contains("470") || normalized.contains("170")) { + return new Matrix(1.402, -0.344136, -0.714136, 1.772); + } + if (normalized.contains("2020")) { + return new Matrix(1.4746, -0.164553, -0.571353, 1.8814); + } + return new Matrix(1.5748, -0.187324, -0.468124, 1.8556); + } + } +} diff --git a/src/main/java/com/github/squi2rel/vp/video/MpvPendingSeek.java b/src/main/java/com/github/squi2rel/vp/video/MpvPendingSeek.java new file mode 100644 index 0000000..1d0992f --- /dev/null +++ b/src/main/java/com/github/squi2rel/vp/video/MpvPendingSeek.java @@ -0,0 +1,23 @@ +package com.github.squi2rel.vp.video; + +import java.util.concurrent.atomic.AtomicLong; + +final class MpvPendingSeek { + private final AtomicLong progress = new AtomicLong(-1L); + + void request(long progressMs) { + progress.set(Math.max(0L, progressMs)); + } + + long peek() { + return progress.get(); + } + + long consume() { + return progress.getAndSet(-1L); + } + + void clearIf(long progressMs) { + progress.compareAndSet(progressMs, -1L); + } +} diff --git a/src/main/java/com/github/squi2rel/vp/video/MpvStreamListener.java b/src/main/java/com/github/squi2rel/vp/video/MpvStreamListener.java index a75c3de..ccdf48f 100644 --- a/src/main/java/com/github/squi2rel/vp/video/MpvStreamListener.java +++ b/src/main/java/com/github/squi2rel/vp/video/MpvStreamListener.java @@ -3,10 +3,15 @@ import com.github.squi2rel.vp.VideoPlayerMain; import com.github.squi2rel.vp.provider.MediaAddressPolicy; import com.github.squi2rel.vp.provider.VideoInfo; +import com.github.squi2rel.vp.video.MpvLibrary.LibMpv; +import com.github.squi2rel.vp.video.MpvLibrary.MpvEvent; +import com.github.squi2rel.vp.video.MpvLibrary.MpvEventEndFile; import com.sun.jna.Memory; import com.sun.jna.Native; import com.sun.jna.Pointer; +import com.sun.jna.ptr.PointerByReference; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.Locale; @@ -20,26 +25,46 @@ final class MpvStreamListener implements IVideoListener { private static final long TIMEOUT_MS = 30_000; private static final long PROPERTY_POLL_INTERVAL_MS = 100; + private static final String AUDIO_METER_LABEL = "videoplayer_audio_meter"; + private static final String AUDIO_METER_FILTER = "@" + AUDIO_METER_LABEL + ":lavfi=[astats=metadata=1:reset=1]"; + private static final String COLOR_METER_LABEL = "videoplayer_color_meter"; + private static final String COLOR_METER_FILTER = "@" + COLOR_METER_LABEL + + ":lavfi=[fps=10,scale=32:18:flags=area,format=pix_fmts=yuv444p,signalstats]"; private static final Set ACTIVE = ConcurrentHashMap.newKeySet(); + private static final MpvTelemetryPermitPool TELEMETRY_PERMITS = new MpvTelemetryPermitPool(32); + private static final long SHUTDOWN_MONITOR_MS = 5_000L; private final LibMpv lib; private final VideoInfo info; + private final boolean telemetry; + private final boolean audioAvailable; + private final boolean videoAvailable; private final AtomicBoolean released = new AtomicBoolean(false); private final AtomicBoolean finished = new AtomicBoolean(false); private final AtomicBoolean started = new AtomicBoolean(false); + private final AtomicBoolean telemetryPermit = new AtomicBoolean(false); private final MpvProgressClock progressClock = new MpvProgressClock(); + private final MpvPendingSeek pendingSeek = new MpvPendingSeek(); private final Object commandLock = new Object(); private Thread thread; private volatile Pointer handle; + private volatile AudioLevelSnapshot audioLevel = AudioLevelSnapshot.unsupported(); + private volatile VideoColorSnapshot videoColor = VideoColorSnapshot.unsupported(); private Consumer playing = seekable -> {}; private Runnable stopped = () -> {}; private Runnable errored = () -> {}; private Runnable timeout = () -> {}; - MpvStreamListener(VideoInfo info) { + MpvStreamListener(VideoInfo info, boolean telemetry) { this.lib = MpvLibrary.get(); this.info = info; + this.telemetry = telemetry; + boolean audioOnly = info != null && (VideoParams.isAudioOnly(info.params()) + || VideoParams.looksAudioOnlyPath(info.path()) || VideoParams.looksAudioOnlyPath(info.rawPath())); + boolean videoOnly = info != null && VideoParams.isVideoOnly(info.params()); + this.audioAvailable = !videoOnly; + this.videoAvailable = !audioOnly; } static void verifyAvailable() { @@ -65,9 +90,20 @@ static void verifyAvailable() { } static void shutdown() { - for (MpvStreamListener listener : List.copyOf(ACTIVE)) { - listener.cancel(); - } + List listeners = List.copyOf(ACTIVE); + for (MpvStreamListener listener : listeners) listener.cancel(); + ListenerShutdownMonitor.start( + "VideoPlayer-MPV-shutdown-monitor", + listeners, + ACTIVE::contains, + SHUTDOWN_MONITOR_MS, + () -> { + }, + remaining -> VideoPlayerMain.LOGGER.warn( + "{} MPV stream listener(s) did not exit within {} ms", + remaining, SHUTDOWN_MONITOR_MS + ) + ); } private static void setOptionString(LibMpv lib, Pointer ctx, String name, String value) { @@ -85,15 +121,17 @@ public long getProgress() { @Override public void setProgress(long progress) { - Pointer ctx = handle; - if (ctx == null || finished.get()) return; long target = Math.max(0, progress); + pendingSeek.request(target); progressClock.seekTo(target); + Pointer ctx = handle; + if (ctx == null || finished.get()) return; synchronized (commandLock) { ctx = handle; if (ctx == null || finished.get()) return; try { command(ctx, "seek", String.format(Locale.ROOT, "%.3f", target / 1000.0), "absolute", "exact"); + pendingSeek.clearIf(target); } catch (RuntimeException e) { VideoPlayerMain.LOGGER.warn("Failed to seek MPV stream listener", e); } @@ -126,15 +164,41 @@ public void timeout(Runnable timeout) { } @Override - public void listen() { - released.set(false); - finished.set(false); - started.set(false); - progressClock.reset(false); - ACTIVE.add(this); - thread = new Thread(this::run, "VideoPlayer-MPV-stream"); - thread.setDaemon(true); - thread.start(); + public AudioLevelSnapshot audioLevel() { + return audioLevel; + } + + @Override + public VideoColorSnapshot videoColor() { + return videoColor; + } + + @Override + public synchronized void listen() { + if (telemetry) { + if (!telemetryPermit.compareAndSet(false, true)) { + throw new IllegalStateException("MPV telemetry listener is already active"); + } + if (!TELEMETRY_PERMITS.acquire()) { + telemetryPermit.set(false); + throw new IllegalStateException("MPV telemetry listener limit exceeded"); + } + } + try { + released.set(false); + finished.set(false); + started.set(false); + progressClock.reset(false); + resetTelemetry(); + ACTIVE.add(this); + thread = new Thread(this::run, "VideoPlayer-MPV-stream"); + thread.setDaemon(true); + thread.start(); + } catch (RuntimeException | Error error) { + ACTIVE.remove(this); + releaseTelemetryPermit(); + throw error; + } } @Override @@ -158,9 +222,15 @@ private void run() { } setOptionString(ctx, "config", "no"); setOptionString(ctx, "terminal", "no"); - setOptionString(ctx, "vid", "no"); + if (telemetry && videoAvailable) { + setOptionString(ctx, "vo", "null"); + setOptionString(ctx, "vf", COLOR_METER_FILTER); + } else { + setOptionString(ctx, "vid", "no"); + } setOptionString(ctx, "ao", "null"); setOptionString(ctx, "mute", "yes"); + if (telemetry && audioAvailable) setOptionString(ctx, "af", AUDIO_METER_FILTER); setOptionString(ctx, "network-timeout", "30"); check(ctx, lib.mpv_initialize(ctx), "mpv_initialize"); loadFile(ctx, VideoParams.normalizeStreamPath(info.path()), @@ -177,7 +247,7 @@ private void run() { long now = System.currentTimeMillis(); if (now - lastPoll >= PROPERTY_POLL_INTERVAL_MS) { lastPoll = now; - refreshProgress(ctx); + refreshProperties(ctx); } if (!started.get() && now >= deadline) { completeTimeout(); @@ -200,16 +270,26 @@ private void run() { } } ACTIVE.remove(this); + releaseTelemetryPermit(); } } + private void releaseTelemetryPermit() { + if (telemetryPermit.compareAndSet(true, false)) TELEMETRY_PERMITS.release(); + } + private void handleEvent(Pointer ctx, MpvEvent event) { switch (event.event_id) { case MPV_EVENT_NONE -> { } case MPV_EVENT_FILE_LOADED -> { started.set(true); - refreshProgress(ctx); + long target = pendingSeek.consume(); + if (target >= 0L) { + command(ctx, "seek", String.format(Locale.ROOT, "%.3f", target / 1000.0), "absolute", "exact"); + progressClock.seekTo(target); + } + refreshProperties(ctx); playing.accept(Boolean.TRUE.equals(getFlag(ctx, "seekable"))); } case MPV_EVENT_END_FILE -> { @@ -239,6 +319,48 @@ private void refreshProgress(Pointer ctx) { } } + private void refreshProperties(Pointer ctx) { + refreshProgress(ctx); + if (!telemetry) return; + long now = System.currentTimeMillis(); + if (!audioAvailable) { + audioLevel = AudioLevelSnapshot.noAudio(); + } else { + String metadata = getString(ctx, "af-metadata/" + AUDIO_METER_LABEL); + audioLevel = updateAudioSnapshot(audioLevel, metadata, now); + } + if (!videoAvailable) { + videoColor = VideoColorSnapshot.noVideo(); + } else { + String metadata = getString(ctx, "vf-metadata/" + COLOR_METER_LABEL); + videoColor = updateColorSnapshot( + videoColor, + metadata, + getString(ctx, "video-params/colormatrix"), + getString(ctx, "video-params/colorlevels"), + now + ); + } + } + + static AudioLevelSnapshot updateAudioSnapshot(AudioLevelSnapshot current, String metadata, long sampledAtMs) { + return metadata == null ? current : MpvAudioLevelParser.parse(metadata, sampledAtMs); + } + + static VideoColorSnapshot updateColorSnapshot(VideoColorSnapshot current, String metadata, + String colorMatrix, String colorLevels, long sampledAtMs) { + return metadata == null ? current : MpvFrameColorParser.parse( + metadata, colorMatrix, colorLevels, sampledAtMs + ); + } + + private void resetTelemetry() { + audioLevel = telemetry ? audioAvailable ? AudioLevelSnapshot.waiting() : AudioLevelSnapshot.noAudio() + : AudioLevelSnapshot.unsupported(); + videoColor = telemetry ? videoAvailable ? VideoColorSnapshot.waiting() : VideoColorSnapshot.noVideo() + : VideoColorSnapshot.unsupported(); + } + private void completeStopped() { if (!finished.compareAndSet(false, true)) return; stopped.run(); @@ -293,6 +415,19 @@ private Boolean getFlag(Pointer ctx, String name) { return result < 0 ? null : data.getInt(0) != 0; } + private String getString(Pointer ctx, String name) { + PointerByReference reference = new PointerByReference(); + int result = lib.mpv_get_property(ctx, name, MPV_FORMAT_STRING, reference.getPointer()); + if (result < 0) return null; + Pointer value = reference.getValue(); + if (value == null) return null; + try { + return value.getString(0, StandardCharsets.UTF_8.name()); + } finally { + lib.mpv_free(value); + } + } + private static Memory intMemory(int value) { Memory data = new Memory(Integer.BYTES); data.setInt(0, value); diff --git a/src/main/java/com/github/squi2rel/vp/video/MpvTelemetryPermitPool.java b/src/main/java/com/github/squi2rel/vp/video/MpvTelemetryPermitPool.java new file mode 100644 index 0000000..00ad5a1 --- /dev/null +++ b/src/main/java/com/github/squi2rel/vp/video/MpvTelemetryPermitPool.java @@ -0,0 +1,32 @@ +package com.github.squi2rel.vp.video; + +import java.util.concurrent.atomic.AtomicInteger; + +final class MpvTelemetryPermitPool { + private final int capacity; + private final AtomicInteger used = new AtomicInteger(); + + MpvTelemetryPermitPool(int capacity) { + if (capacity < 1) throw new IllegalArgumentException("capacity must be positive"); + this.capacity = capacity; + } + + boolean acquire() { + while (true) { + int current = used.get(); + if (current >= capacity) return false; + if (used.compareAndSet(current, current + 1)) return true; + } + } + + void release() { + while (true) { + int current = used.get(); + if (current == 0 || used.compareAndSet(current, current - 1)) return; + } + } + + int available() { + return capacity - used.get(); + } +} diff --git a/src/main/java/com/github/squi2rel/vp/video/PlaybackController.java b/src/main/java/com/github/squi2rel/vp/video/PlaybackController.java index 4942c29..aa9d2b8 100644 --- a/src/main/java/com/github/squi2rel/vp/video/PlaybackController.java +++ b/src/main/java/com/github/squi2rel/vp/video/PlaybackController.java @@ -40,6 +40,7 @@ public class PlaybackController { private final DelayedExecutor delayedExecutor; private final ScreenLifecycleToken lifecycleToken; private final Predicate reporterEligibility; + private final PlaybackTelemetryRegistry.Binding telemetryBinding; private IVideoListener listener; private CompletableFuture nextTask; @@ -66,7 +67,7 @@ public PlaybackController(VideoScreen screen, PlaybackQueue queue, ScreenBroadca broadcaster, PlaybackController::resolveQueuedInfo, PlaybackController::resolveIdle, - VideoListeners::from, + info -> VideoListeners.from(screen, info), CompletableFuture.delayedExecutor(0, TimeUnit.MILLISECONDS), stateExecutor(screen), delayedExecutor(screen), @@ -101,6 +102,32 @@ private PlaybackController(VideoScreen screen, PlaybackQueue queue, ScreenBroadc this.delayedExecutor = delayedExecutor; this.lifecycleToken = lifecycleToken; this.reporterEligibility = reporterEligibility == null ? uuid -> false : reporterEligibility; + this.telemetryBinding = lifecycleToken == null ? null : PlaybackTelemetryRegistry.bind( + ScreenKey.of(screen), + requested -> serverExecutor.execute( + () -> applyTelemetryRequest(requested, StreamListener::telemetryProbe) + ) + ); + } + + void applyTelemetryRequest(boolean requested, Function telemetryFactory) { + if (!lifecycleCurrent()) return; + IVideoListener current = listener; + if (!(current instanceof TelemetryVideoListener telemetry)) return; + if (!requested) { + telemetry.detachTelemetry(); + return; + } + VideoInfo info = currentInfo; + if (info == null || telemetryFactory == null) return; + IVideoListener probe; + try { + probe = telemetryFactory.apply(info); + } catch (RuntimeException error) { + LOGGER.warn("Failed to create playback telemetry probe for screen {}", screen.name, error); + return; + } + if (probe != null) telemetry.attachTelemetry(probe); } public void playNext() { @@ -480,6 +507,11 @@ public void stopAndClear(boolean syncPlaylist) { } } + void close() { + if (telemetryBinding != null) telemetryBinding.close(); + stopAndClear(false); + } + private void stopCurrent() { playbackGeneration++; retryVersion++; diff --git a/src/main/java/com/github/squi2rel/vp/video/PlaybackTelemetryRegistry.java b/src/main/java/com/github/squi2rel/vp/video/PlaybackTelemetryRegistry.java new file mode 100644 index 0000000..10beb87 --- /dev/null +++ b/src/main/java/com/github/squi2rel/vp/video/PlaybackTelemetryRegistry.java @@ -0,0 +1,130 @@ +package com.github.squi2rel.vp.video; + +import com.github.squi2rel.vp.VideoPlayerMain; + +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArraySet; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +public final class PlaybackTelemetryRegistry { + public static final int API_VERSION = 1; + static final int MAX_REQUESTED_SCREENS = 1024; + private static final Object REFERENCE_LOCK = new Object(); + private static final ConcurrentHashMap REFERENCES = new ConcurrentHashMap<>(); + private static final ConcurrentHashMap> BINDINGS = new ConcurrentHashMap<>(); + + private PlaybackTelemetryRegistry() { + } + + public static int apiVersion() { + return API_VERSION; + } + + public static Registration acquire(ScreenKey key) { + ScreenKey valid = validate(key); + synchronized (REFERENCE_LOCK) { + AtomicInteger count = REFERENCES.get(valid); + if (count == null) { + if (REFERENCES.size() >= MAX_REQUESTED_SCREENS) { + throw new IllegalStateException("playback telemetry screen limit exceeded"); + } + count = new AtomicInteger(); + REFERENCES.put(valid, count); + } + if (count.incrementAndGet() == 1) publish(valid, true); + } + return new Registration(valid); + } + + public static boolean requested(ScreenKey key) { + AtomicInteger count = key == null ? null : REFERENCES.get(key); + return count != null && count.get() > 0; + } + + static Binding bind(ScreenKey key, Consumer consumer) { + ScreenKey valid = validate(key); + Target target = new Target(Objects.requireNonNull(consumer, "consumer")); + BINDINGS.computeIfAbsent(valid, ignored -> new CopyOnWriteArraySet<>()).add(target); + target.publish(requested(valid)); + return new Binding(valid, target); + } + + private static ScreenKey validate(ScreenKey key) { + if (key == null || key.dimension() == null || key.dimension().isBlank() + || key.areaName() == null || key.areaName().isBlank() + || key.screenName() == null || key.screenName().isBlank()) { + throw new IllegalArgumentException("complete screen key is required"); + } + return key; + } + + private static void publish(ScreenKey key, boolean requested) { + CopyOnWriteArraySet targets = BINDINGS.get(key); + if (targets == null) return; + for (Target target : targets) target.publish(requested); + } + + public static final class Registration implements AutoCloseable { + private final ScreenKey key; + private final AtomicBoolean closed = new AtomicBoolean(); + + private Registration(ScreenKey key) { + this.key = key; + } + + @Override + public void close() { + if (!closed.compareAndSet(false, true)) return; + synchronized (REFERENCE_LOCK) { + AtomicInteger count = REFERENCES.get(key); + if (count != null && count.decrementAndGet() <= 0) { + REFERENCES.remove(key, count); + publish(key, false); + } + } + } + } + + static final class Binding implements AutoCloseable { + private final ScreenKey key; + private final Target target; + private final AtomicBoolean closed = new AtomicBoolean(); + + private Binding(ScreenKey key, Target target) { + this.key = key; + this.target = target; + } + + @Override + public void close() { + if (!closed.compareAndSet(false, true)) return; + BINDINGS.computeIfPresent(key, (ignored, targets) -> { + targets.remove(target); + return targets.isEmpty() ? null : targets; + }); + } + } + + private static final class Target { + private final Consumer consumer; + private final AtomicReference last = new AtomicReference<>(); + + private Target(Consumer consumer) { + this.consumer = consumer; + } + + private void publish(boolean state) { + Boolean previous = last.getAndSet(state); + if (previous != null && previous == state) return; + try { + consumer.accept(state); + } catch (RuntimeException error) { + VideoPlayerMain.LOGGER.warn("Playback telemetry binding failed for state {}", state, error); + } + } + } +} diff --git a/src/main/java/com/github/squi2rel/vp/video/ScreenBroadcaster.java b/src/main/java/com/github/squi2rel/vp/video/ScreenBroadcaster.java index 01cb527..f55e239 100644 --- a/src/main/java/com/github/squi2rel/vp/video/ScreenBroadcaster.java +++ b/src/main/java/com/github/squi2rel/vp/video/ScreenBroadcaster.java @@ -4,10 +4,9 @@ import com.github.squi2rel.vp.i18n.VpTranslation; import com.github.squi2rel.vp.network.ServerPacketHandler; import com.github.squi2rel.vp.network.VideoPackets; -import net.minecraft.server.PlayerManager; -import net.minecraft.server.network.ServerPlayerEntity; - import java.util.List; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.players.PlayerList; import static com.github.squi2rel.vp.DataHolder.server; @@ -20,9 +19,9 @@ public ScreenBroadcaster(VideoScreen screen) { public void send(byte[] data) { if (server == null) return; - PlayerManager pm = server.getPlayerManager(); + PlayerList pm = server.getPlayerList(); for (var uuid : screen.area.playerSnapshot()) { - ServerPlayerEntity player = pm.getPlayer(uuid); + ServerPlayer player = pm.getPlayer(uuid); if (player != null) { ServerPacketHandler.sendTo(player, data); } @@ -32,7 +31,7 @@ public void send(byte[] data) { public void sendTo(java.util.UUID uuid, byte[] data) { if (uuid == null || data == null) return; if (server == null) return; - ServerPlayerEntity player = server.getPlayerManager().getPlayer(uuid); + ServerPlayer player = server.getPlayerList().getPlayer(uuid); if (player != null) { ServerPacketHandler.sendTo(player, data); } @@ -44,11 +43,11 @@ public void syncPlaylist() { public void syncIdlePlay() { if (server == null) return; - PlayerManager pm = server.getPlayerManager(); + PlayerList pm = server.getPlayerList(); byte[] current = null; byte[] legacy = null; for (var uuid : screen.area.playerSnapshot()) { - ServerPlayerEntity player = pm.getPlayer(uuid); + ServerPlayer player = pm.getPlayer(uuid); if (player == null) continue; boolean mutations = DataHolder.supportsIdlePlayMutations(uuid); byte[] data; diff --git a/src/main/java/com/github/squi2rel/vp/video/StreamListener.java b/src/main/java/com/github/squi2rel/vp/video/StreamListener.java index 7856b01..b67660b 100644 --- a/src/main/java/com/github/squi2rel/vp/video/StreamListener.java +++ b/src/main/java/com/github/squi2rel/vp/video/StreamListener.java @@ -19,7 +19,11 @@ public class StreamListener implements IVideoListener { private final IVideoListener delegate; public StreamListener(VideoInfo info) { - this.delegate = create(info); + this(info, false); + } + + public StreamListener(VideoInfo info, boolean telemetry) { + this.delegate = create(info, telemetry); } public static boolean accept(VideoInfo info) { @@ -108,6 +112,18 @@ public static synchronized void shutdown() { MpvLibrary.resetLoadState(); } + static synchronized IVideoListener telemetryProbe(VideoInfo info) { + if (!accept(info)) return null; + if (!mpvAvailable && mpvError == null) loadBackend(NativePackageManager.BACKEND_MPV, false); + if (!mpvAvailable) return null; + try { + return new MpvStreamListener(info, true); + } catch (RuntimeException error) { + VideoPlayerMain.LOGGER.warn("Failed to create MPV telemetry probe", error); + return null; + } + } + private static boolean loadBackend(String backend, boolean fallback) { if (NativePackageManager.BACKEND_MPV.equals(NativeDownloadConfig.normalizeBackend(backend))) { mpvError = MpvLibrary.loadError(); @@ -136,18 +152,17 @@ private static boolean loadBackend(String backend, boolean fallback) { return false; } - private static IVideoListener create(VideoInfo info) { + private static IVideoListener create(VideoInfo info, boolean telemetry) { load(); + if (telemetry) { + if (!mpvAvailable && mpvError == null) loadBackend(NativePackageManager.BACKEND_MPV, false); + } String first = VideoPlayerMain.android ? NativePackageManager.BACKEND_VLC : NativeDownloadConfig.normalizeBackend(preferredBackend); - if (NativePackageManager.BACKEND_MPV.equals(first)) { - if (mpvAvailable) return new MpvStreamListener(info); - if (vlcAvailable) return new VlcStreamListener(info); - } else { - if (vlcAvailable) return new VlcStreamListener(info); - if (mpvAvailable) return new MpvStreamListener(info); - } + String selected = selectBackend(telemetry, first, mpvAvailable, vlcAvailable); + if (NativePackageManager.BACKEND_MPV.equals(selected)) return new MpvStreamListener(info, telemetry); + if (NativePackageManager.BACKEND_VLC.equals(selected)) return new VlcStreamListener(info); IllegalStateException error = new IllegalStateException("Stream listener backend is not loaded"); if (mpvError != null) error.addSuppressed(mpvError); @@ -155,6 +170,16 @@ private static IVideoListener create(VideoInfo info) { throw error; } + static String selectBackend(boolean telemetry, String preferred, boolean mpv, boolean vlc) { + if (telemetry && mpv) return NativePackageManager.BACKEND_MPV; + if (NativePackageManager.BACKEND_MPV.equals(preferred)) { + if (mpv) return NativePackageManager.BACKEND_MPV; + return vlc ? NativePackageManager.BACKEND_VLC : null; + } + if (vlc) return NativePackageManager.BACKEND_VLC; + return mpv ? NativePackageManager.BACKEND_MPV : null; + } + @Override public long getProgress() { return delegate.getProgress(); @@ -190,6 +215,16 @@ public void timeout(Runnable timeout) { delegate.timeout(timeout); } + @Override + public AudioLevelSnapshot audioLevel() { + return delegate.audioLevel(); + } + + @Override + public VideoColorSnapshot videoColor() { + return delegate.videoColor(); + } + @Override public void listen() { delegate.listen(); diff --git a/src/main/java/com/github/squi2rel/vp/video/TelemetryVideoListener.java b/src/main/java/com/github/squi2rel/vp/video/TelemetryVideoListener.java new file mode 100644 index 0000000..a124fdb --- /dev/null +++ b/src/main/java/com/github/squi2rel/vp/video/TelemetryVideoListener.java @@ -0,0 +1,141 @@ +package com.github.squi2rel.vp.video; + +import java.util.Objects; +import java.util.function.Consumer; + +final class TelemetryVideoListener implements IVideoListener { + private final IVideoListener playback; + private final Object lock = new Object(); + private volatile IVideoListener telemetry; + private boolean listening; + + TelemetryVideoListener(IVideoListener playback, IVideoListener telemetry) { + this.playback = Objects.requireNonNull(playback, "playback"); + this.telemetry = telemetry; + } + + @Override + public long getProgress() { + return playback.getProgress(); + } + + @Override + public void setProgress(long progress) { + playback.setProgress(progress); + IVideoListener current = telemetry; + if (current != null) current.setProgress(progress); + } + + @Override + public boolean isPlaying() { + return playback.isPlaying(); + } + + @Override + public void playing(Consumer playing) { + playback.playing(playing); + } + + @Override + public void stopped(Runnable stopped) { + playback.stopped(stopped); + } + + @Override + public void errored(Runnable errored) { + playback.errored(errored); + } + + @Override + public void timeout(Runnable timeout) { + playback.timeout(timeout); + } + + @Override + public AudioLevelSnapshot audioLevel() { + IVideoListener current = telemetry; + return current == null ? AudioLevelSnapshot.unsupported() : current.audioLevel(); + } + + @Override + public VideoColorSnapshot videoColor() { + IVideoListener current = telemetry; + return current == null ? VideoColorSnapshot.unsupported() : current.videoColor(); + } + + @Override + public void listen() { + playback.listen(); + IVideoListener current; + synchronized (lock) { + listening = true; + current = telemetry; + } + if (current != null) startTelemetry(current); + } + + @Override + public void cancel() { + IVideoListener current; + synchronized (lock) { + listening = false; + current = telemetry; + telemetry = null; + } + try { + playback.cancel(); + } finally { + if (current != null) current.cancel(); + } + } + + boolean attachTelemetry(IVideoListener next) { + if (next == null) return false; + IVideoListener previous; + boolean start; + synchronized (lock) { + if (telemetry == next) return false; + previous = telemetry; + telemetry = next; + start = listening; + } + if (previous != null) previous.cancel(); + if (start) startTelemetry(next); + return true; + } + + boolean detachTelemetry() { + IVideoListener previous; + synchronized (lock) { + previous = telemetry; + telemetry = null; + } + if (previous == null) return false; + previous.cancel(); + return true; + } + + private void startTelemetry(IVideoListener target) { + target.playing(ignored -> { + }); + target.stopped(() -> { + }); + target.errored(() -> failTelemetry(target)); + target.timeout(() -> failTelemetry(target)); + long progress = playback.getProgress(); + if (progress >= 0L) target.setProgress(progress); + try { + target.listen(); + } catch (Throwable error) { + failTelemetry(target); + } + } + + private void failTelemetry(IVideoListener target) { + synchronized (lock) { + if (telemetry != target) return; + telemetry = null; + } + target.cancel(); + } +} diff --git a/src/main/java/com/github/squi2rel/vp/video/VideoArea.java b/src/main/java/com/github/squi2rel/vp/video/VideoArea.java index 03712fa..89d0bb8 100644 --- a/src/main/java/com/github/squi2rel/vp/video/VideoArea.java +++ b/src/main/java/com/github/squi2rel/vp/video/VideoArea.java @@ -2,7 +2,6 @@ import com.github.squi2rel.vp.network.ByteBufUtils; import io.netty.buffer.ByteBuf; -import net.minecraft.util.math.Vec3d; import org.joml.Vector3f; import java.util.ArrayList; @@ -10,6 +9,7 @@ import java.util.List; import java.util.UUID; import java.util.function.Consumer; +import net.minecraft.world.phys.Vec3; public class VideoArea { public static final int MAX_AREAS_PER_WORLD = 256; @@ -46,7 +46,7 @@ public void initServer() { players = new HashSet<>(); } - public boolean inBounds(Vec3d v) { + public boolean inBounds(Vec3 v) { return min.x <= v.x && min.y <= v.y && min.z <= v.z && v.x < max.x && v.y < max.y && v.z < max.z; } diff --git a/src/main/java/com/github/squi2rel/vp/video/VideoColorSnapshot.java b/src/main/java/com/github/squi2rel/vp/video/VideoColorSnapshot.java new file mode 100644 index 0000000..2a5ffa1 --- /dev/null +++ b/src/main/java/com/github/squi2rel/vp/video/VideoColorSnapshot.java @@ -0,0 +1,33 @@ +package com.github.squi2rel.vp.video; + +public record VideoColorSnapshot(Status status, int rgb, float luminance, long sampledAtMs) { + public VideoColorSnapshot { + status = status == null ? Status.WAITING : status; + rgb = Math.clamp(rgb, 0, 0xFFFFFF); + luminance = Float.isFinite(luminance) ? Math.clamp(luminance, 0f, 1f) : 0f; + sampledAtMs = Math.max(0L, sampledAtMs); + } + + public static VideoColorSnapshot available(int rgb, float luminance, long sampledAtMs) { + return new VideoColorSnapshot(Status.AVAILABLE, rgb, luminance, sampledAtMs); + } + + public static VideoColorSnapshot waiting() { + return new VideoColorSnapshot(Status.WAITING, 0, 0f, 0L); + } + + public static VideoColorSnapshot noVideo() { + return new VideoColorSnapshot(Status.NO_VIDEO, 0, 0f, 0L); + } + + public static VideoColorSnapshot unsupported() { + return new VideoColorSnapshot(Status.UNSUPPORTED, 0, 0f, 0L); + } + + public enum Status { + AVAILABLE, + WAITING, + NO_VIDEO, + UNSUPPORTED + } +} diff --git a/src/main/java/com/github/squi2rel/vp/video/VideoListeners.java b/src/main/java/com/github/squi2rel/vp/video/VideoListeners.java index 16d6d9e..767d864 100644 --- a/src/main/java/com/github/squi2rel/vp/video/VideoListeners.java +++ b/src/main/java/com/github/squi2rel/vp/video/VideoListeners.java @@ -4,7 +4,27 @@ import com.github.squi2rel.vp.provider.bilibili.BiliBiliVideoProvider; import com.github.squi2rel.vp.provider.YouTubeProvider; +import java.util.function.Function; + public class VideoListeners { + public static IVideoListener from(VideoScreen screen, VideoInfo info) { + return from(screen, info, StreamListener::telemetryProbe); + } + + static IVideoListener from(VideoScreen screen, VideoInfo info, + Function telemetryFactory) { + IVideoListener playback = from(info); + if (playback == null || telemetryFactory == null || !supportsNativeTelemetry(screen, info)) return playback; + IVideoListener telemetry = null; + if (PlaybackTelemetryRegistry.requested(ScreenKey.of(screen))) { + try { + telemetry = telemetryFactory.apply(info); + } catch (RuntimeException ignored) { + } + } + return new TelemetryVideoListener(playback, telemetry); + } + public static IVideoListener from(VideoInfo info) { if (PlayerListener.accept(info)) { return new PlayerListener(); @@ -24,6 +44,18 @@ public static IVideoListener from(VideoInfo info) { return null; } + static boolean requiresNativeTelemetry(VideoScreen screen, VideoInfo info) { + return supportsNativeTelemetry(screen, info) + && PlaybackTelemetryRegistry.requested(ScreenKey.of(screen)); + } + + private static boolean supportsNativeTelemetry(VideoScreen screen, VideoInfo info) { + return screen != null + && info != null + && StreamListener.accept(info) + && !PlayerListener.accept(info); + } + public static boolean requiresNativeStreamListener(VideoInfo info) { return info != null && !PlayerListener.accept(info) diff --git a/src/main/java/com/github/squi2rel/vp/video/VideoScreen.java b/src/main/java/com/github/squi2rel/vp/video/VideoScreen.java index 40efccc..71ba9d2 100644 --- a/src/main/java/com/github/squi2rel/vp/video/VideoScreen.java +++ b/src/main/java/com/github/squi2rel/vp/video/VideoScreen.java @@ -556,7 +556,7 @@ public void remove() { serverActive = false; serverScreenEpoch++; if (admissions != null) admissions.close(); - if (playback != null) playback.stopAndClear(false); + if (playback != null) playback.close(); } public void playNext() { diff --git a/src/main/java/com/github/squi2rel/vp/video/VlcLibrary.java b/src/main/java/com/github/squi2rel/vp/video/VlcLibrary.java index e8863ac..18cc7ac 100644 --- a/src/main/java/com/github/squi2rel/vp/video/VlcLibrary.java +++ b/src/main/java/com/github/squi2rel/vp/video/VlcLibrary.java @@ -47,10 +47,6 @@ static LibVlc get() { if (lib != null) return lib; if (loadError != null) throw unavailable(loadError); Throwable last = null; - if (VideoPlayerMain.android && !NativePackageManager.ensureBundledAndroidVlc()) { - loadError = new IllegalStateException("Bundled Android ARM64 VLC runtime is unavailable for " + NativeDownloadConfig.platformKey()); - throw unavailable(loadError); - } Optional prepared = NativePackageManager.prepareForLoad(NativePackageManager.BACKEND_VLC); if (prepared.isPresent()) { NativePackageManager.PreparedNativePackage nativePackage = prepared.get(); @@ -70,7 +66,9 @@ static LibVlc get() { } NativeLibraryLoader.clearWindowsDllDirectory(); if (VideoPlayerMain.android) { - loadError = last == null ? new IllegalStateException("Bundled Android VLC runtime could not be prepared") : last; + loadError = last == null + ? new IllegalStateException("Android VLC runtime is not installed or could not be prepared") + : last; throw unavailable(loadError); } if ("windows".equals(NativeDownloadConfig.osKey())) { diff --git a/src/main/java/com/github/squi2rel/vp/video/VlcStreamListener.java b/src/main/java/com/github/squi2rel/vp/video/VlcStreamListener.java index 53b9885..eebb97c 100644 --- a/src/main/java/com/github/squi2rel/vp/video/VlcStreamListener.java +++ b/src/main/java/com/github/squi2rel/vp/video/VlcStreamListener.java @@ -15,6 +15,7 @@ final class VlcStreamListener implements IVideoListener { private static final long TIMEOUT_MS = 30_000; private static final long LOOP_SLEEP_MS = 50; + private static final long SHUTDOWN_MONITOR_MS = 5_000L; private static final int[] MEDIA_PLAYER_EVENTS = { VlcLibrary.LIBVLC_MEDIA_PLAYER_PLAYING, VlcLibrary.LIBVLC_MEDIA_PLAYER_STOPPED, @@ -106,31 +107,35 @@ static synchronized void resetLoadState() { } static void shutdown() { + shutdown(VlcStreamListener::releaseInstance); + } + + static void shutdown(Consumer releaser) { List listeners; + Pointer loadedInstance; synchronized (VlcStreamListener.class) { shutDown = true; listeners = List.copyOf(ACTIVE); - } - for (VlcStreamListener listener : listeners) { - listener.cancel(); - } - for (VlcStreamListener listener : listeners) { - Thread running = listener.thread; - if (running == null || running == Thread.currentThread()) continue; - try { - running.join(2_000L); - } catch (InterruptedException interrupted) { - Thread.currentThread().interrupt(); - break; - } - } - Pointer loadedInstance; - synchronized (VlcStreamListener.class) { loadedInstance = instance; instance = null; loadError = null; loadAttempted = false; } + for (VlcStreamListener listener : listeners) listener.cancel(); + ListenerShutdownMonitor.start( + "VideoPlayer-VLC-shutdown-monitor", + listeners, + ACTIVE::contains, + SHUTDOWN_MONITOR_MS, + () -> releaser.accept(loadedInstance), + remaining -> VideoPlayerMain.LOGGER.warn( + "{} VLC stream listener(s) did not exit within {} ms", + remaining, SHUTDOWN_MONITOR_MS + ) + ); + } + + static void releaseInstance(Pointer loadedInstance) { if (loadedInstance != null) { try { VlcLibrary.releaseInstance(loadedInstance); diff --git a/src/main/resources/assets/videoplayer/native/vlc/android_arm64-v8a.NOTICE.txt b/src/main/resources/assets/videoplayer/native/vlc/android_arm64-v8a.NOTICE.txt deleted file mode 100644 index bda0221..0000000 --- a/src/main/resources/assets/videoplayer/native/vlc/android_arm64-v8a.NOTICE.txt +++ /dev/null @@ -1,21 +0,0 @@ -Bundled Android ARM64 VLC runtime - -Release asset: -https://github.com/squi2rel/VideoPlayer-Library/releases/download/runtime-20260712-064900/libvlc-android-arm64-v8a.zip - -SHA-256: -dbae70c264a9d86cd8d7fbd7ca35388cbe973de03636c08f0ac8d7cdb493f9ec - -The libVLC binaries originate from org.videolan.android:libvlc-all:3.7.5. -LibVLC-Android is distributed under the GNU Lesser General Public License 2.1. -This Android build embeds VLC static modules in libvlc.so and does not require a separate plugins directory. -Project and corresponding source: -https://code.videolan.org/videolan/libvlcjni -https://code.videolan.org/videolan/vlc -https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html - -libc++_shared.so originates from the Android NDK LLVM runtime and is covered by the LLVM/Apache 2.0 license terms with the LLVM exception. -https://github.com/llvm/llvm-project/blob/main/LICENSE.TXT - -libvlc_jvm_bridge.so is built from the corresponding bridge source published with the runtime release: -https://github.com/squi2rel/VideoPlayer-Library/blob/runtime-20260712-064900/scripts/ci/vlc_jvm_bridge.c diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index b1f4855..beab1ec 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -33,9 +33,9 @@ } ], "depends": { - "fabricloader": ">=0.17.3", - "minecraft": "~1.21.11", - "java": ">=21", + "fabricloader": ">=${loader_min_version}", + "minecraft": "${minecraft_dependency}", + "java": ">=${java_version}", "fabric-api": "*" }, "suggests": { diff --git a/src/test/java/com/github/squi2rel/vp/AndroidVlcPackagingTest.java b/src/test/java/com/github/squi2rel/vp/AndroidVlcPackagingTest.java new file mode 100644 index 0000000..b7151f9 --- /dev/null +++ b/src/test/java/com/github/squi2rel/vp/AndroidVlcPackagingTest.java @@ -0,0 +1,18 @@ +package com.github.squi2rel.vp; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertNull; + +class AndroidVlcPackagingTest { + private static final String ANDROID_VLC_RESOURCE = + "/assets/videoplayer/native/vlc/android_arm64-v8a.zip"; + private static final String ANDROID_VLC_NOTICE = + "/assets/videoplayer/native/vlc/android_arm64-v8a.NOTICE.txt"; + + @Test + void androidVlcRuntimeIsNotBundled() { + assertNull(AndroidVlcPackagingTest.class.getResource(ANDROID_VLC_RESOURCE)); + assertNull(AndroidVlcPackagingTest.class.getResource(ANDROID_VLC_NOTICE)); + } +} diff --git a/src/test/java/com/github/squi2rel/vp/BundledAndroidVlcTest.java b/src/test/java/com/github/squi2rel/vp/BundledAndroidVlcTest.java deleted file mode 100644 index ccb2747..0000000 --- a/src/test/java/com/github/squi2rel/vp/BundledAndroidVlcTest.java +++ /dev/null @@ -1,143 +0,0 @@ -package com.github.squi2rel.vp; - -import org.junit.jupiter.api.Test; - -import java.io.ByteArrayInputStream; -import java.io.InputStream; -import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; -import java.util.HexFormat; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.zip.ZipEntry; -import java.util.zip.ZipInputStream; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class BundledAndroidVlcTest { - @Test - void bundlesVerifiedArm64RuntimeWithJvmBridge() throws Exception { - byte[] zip; - try (InputStream input = BundledAndroidVlcTest.class.getResourceAsStream(NativePackageManager.BUNDLED_ANDROID_VLC_RESOURCE)) { - assertNotNull(input); - zip = input.readAllBytes(); - } - - String digest = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(zip)); - assertEquals(NativePackageManager.BUNDLED_ANDROID_VLC_SHA256, digest); - - Set entries = new HashSet<>(); - Map libraries = new HashMap<>(); - try (ZipInputStream input = new ZipInputStream(new ByteArrayInputStream(zip))) { - ZipEntry entry; - while ((entry = input.getNextEntry()) != null) { - if (!entry.isDirectory()) { - entries.add(entry.getName()); - if (entry.getName().endsWith(".so")) libraries.put(entry.getName(), input.readAllBytes()); - } - } - } - assertTrue(entries.contains("libvlc.so")); - assertTrue(entries.contains("libvlcjni.so")); - assertTrue(entries.contains("libvlc_jvm_bridge.so")); - assertTrue(entries.contains("libc++_shared.so")); - - Set androidSystemLibraries = Set.of( - "libEGL.so", "libGLESv2.so", "libandroid.so", "libc.so", "libdl.so", - "liblog.so", "libm.so", "libmediandk.so" - ); - for (Map.Entry library : libraries.entrySet()) { - ElfInfo elf = readElf(library.getValue()); - assertEquals(2, elf.elfClass()); - assertEquals(1, elf.endian()); - assertEquals(183, elf.machine()); - for (String needed : elf.needed()) { - assertTrue(libraries.containsKey(needed) || androidSystemLibraries.contains(needed), - () -> library.getKey() + " requires unavailable " + needed); - } - } - assertTrue(contains(libraries.get("libvlc.so"), "vlc_static_modules".getBytes(StandardCharsets.US_ASCII))); - } - - private static ElfInfo readElf(byte[] data) { - assertTrue(data.length >= 64); - assertEquals(0x7f, data[0] & 0xff); - assertEquals('E', data[1]); - assertEquals('L', data[2]); - assertEquals('F', data[3]); - ByteBuffer buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN); - int elfClass = data[4] & 0xff; - int endian = data[5] & 0xff; - int machine = buffer.getShort(18) & 0xffff; - long programOffset = buffer.getLong(32); - int programEntrySize = buffer.getShort(54) & 0xffff; - int programCount = buffer.getShort(56) & 0xffff; - ArrayList loads = new ArrayList<>(); - long dynamicOffset = -1; - long dynamicSize = 0; - for (int i = 0; i < programCount; i++) { - int offset = Math.toIntExact(programOffset + (long) i * programEntrySize); - int type = buffer.getInt(offset); - long fileOffset = buffer.getLong(offset + 8); - long virtualAddress = buffer.getLong(offset + 16); - long fileSize = buffer.getLong(offset + 32); - if (type == 1) loads.add(new LoadSegment(virtualAddress, virtualAddress + fileSize, fileOffset)); - if (type == 2) { - dynamicOffset = fileOffset; - dynamicSize = fileSize; - } - } - long stringTableAddress = -1; - ArrayList neededOffsets = new ArrayList<>(); - if (dynamicOffset >= 0) { - for (long offset = dynamicOffset; offset + 16 <= dynamicOffset + dynamicSize; offset += 16) { - long tag = buffer.getLong(Math.toIntExact(offset)); - long value = buffer.getLong(Math.toIntExact(offset + 8)); - if (tag == 0) break; - if (tag == 1) neededOffsets.add(value); - if (tag == 5) stringTableAddress = value; - } - } - long stringTableOffset = -1; - for (LoadSegment load : loads) { - if (stringTableAddress >= load.start() && stringTableAddress < load.end()) { - stringTableOffset = load.fileOffset() + stringTableAddress - load.start(); - break; - } - } - ArrayList needed = new ArrayList<>(); - if (stringTableOffset >= 0) { - for (long neededOffset : neededOffsets) { - int start = Math.toIntExact(stringTableOffset + neededOffset); - int end = start; - while (end < data.length && data[end] != 0) end++; - needed.add(new String(data, start, end - start, StandardCharsets.UTF_8)); - } - } - return new ElfInfo(elfClass, endian, machine, List.copyOf(needed)); - } - - private static boolean contains(byte[] data, byte[] needle) { - if (data == null || needle.length == 0 || data.length < needle.length) return false; - for (int i = 0; i <= data.length - needle.length; i++) { - int j = 0; - while (j < needle.length && data[i + j] == needle[j]) j++; - if (j == needle.length) return true; - } - return false; - } - - private record LoadSegment(long start, long end, long fileOffset) { - } - - private record ElfInfo(int elfClass, int endian, int machine, List needed) { - } -} diff --git a/src/test/java/com/github/squi2rel/vp/LocalPlaybackResolutionPolicyTest.java b/src/test/java/com/github/squi2rel/vp/LocalPlaybackResolutionPolicyTest.java new file mode 100644 index 0000000..6a03211 --- /dev/null +++ b/src/test/java/com/github/squi2rel/vp/LocalPlaybackResolutionPolicyTest.java @@ -0,0 +1,83 @@ +package com.github.squi2rel.vp; + +import com.github.squi2rel.vp.provider.VideoInfo; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LocalPlaybackResolutionPolicyTest { + @Test + void skipsLocalResolutionForPlayableServerResolvedYouTubeLiveStream() { + VideoInfo info = youtube("https://video.example/live", false, System.currentTimeMillis() + 60_000L); + + assertFalse(LocalPlaybackResolutionPolicy.shouldResolve(info)); + } + + @Test + void resolvesYouTubeLiveStreamLocallyWhenServerPathIsMissing() { + VideoInfo info = youtube("", false, -1L); + + assertTrue(LocalPlaybackResolutionPolicy.shouldResolve(info)); + } + + @Test + void resolvesYouTubeLiveStreamLocallyWhenServerPathExpired() { + VideoInfo info = youtube("https://video.example/live", false, System.currentTimeMillis() - 1L); + + assertTrue(LocalPlaybackResolutionPolicy.shouldResolve(info)); + } + + @Test + void continuesResolvingYouTubeVideoOnTheClient() { + VideoInfo info = youtube("https://video.example/vod", true, System.currentTimeMillis() + 60_000L); + + assertTrue(LocalPlaybackResolutionPolicy.shouldResolve(info)); + } + + @Test + void continuesResolvingNonYouTubeLiveSourcesOnTheClient() { + VideoInfo info = new VideoInfo( + "player", + "live", + "https://video.example/live", + "https://example.com/live", + -1L, + false, + new String[0], + 0L + ); + + assertTrue(LocalPlaybackResolutionPolicy.shouldResolve(info)); + } + + @Test + void skipsLocalResolutionWithoutAResolvableRawPath() { + VideoInfo info = new VideoInfo( + "player", + "direct", + "https://video.example/direct", + "", + -1L, + true, + new String[0], + 1_000L + ); + + assertFalse(LocalPlaybackResolutionPolicy.shouldResolve(info)); + assertFalse(LocalPlaybackResolutionPolicy.shouldResolve(null)); + } + + private static VideoInfo youtube(String path, boolean seekable, long expire) { + return new VideoInfo( + "player", + "youtube", + path, + "https://www.youtube.com/watch?v=hotfix-test", + expire, + seekable, + new String[0], + seekable ? 1_000L : 0L + ); + } +} diff --git a/src/test/java/com/github/squi2rel/vp/NativeDownloadConfigAndroidTest.java b/src/test/java/com/github/squi2rel/vp/NativeDownloadConfigAndroidTest.java index ce6fd62..3b24ef9 100644 --- a/src/test/java/com/github/squi2rel/vp/NativeDownloadConfigAndroidTest.java +++ b/src/test/java/com/github/squi2rel/vp/NativeDownloadConfigAndroidTest.java @@ -44,7 +44,7 @@ void unsupportedAndroidEntriesAreRemovedFromLoadedConfiguration() { } @Test - void bundledDownloadListContainsNoUnsupportedAndroidRuntime() throws Exception { + void defaultDownloadListContainsOnlySupportedAndroidRuntime() throws Exception { try (InputStream input = NativeDownloadConfigAndroidTest.class.getResourceAsStream("/assets/videoplayer/native-downloads.json")) { assertTrue(input != null); String json = new String(input.readAllBytes(), StandardCharsets.UTF_8); diff --git a/src/test/java/com/github/squi2rel/vp/NativePackageManagerCancellationTest.java b/src/test/java/com/github/squi2rel/vp/NativePackageManagerCancellationTest.java new file mode 100644 index 0000000..1c04e89 --- /dev/null +++ b/src/test/java/com/github/squi2rel/vp/NativePackageManagerCancellationTest.java @@ -0,0 +1,117 @@ +package com.github.squi2rel.vp; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class NativePackageManagerCancellationTest { + @AfterEach + void cancelDownloads() { + NativePackageManager.cancelActiveDownloads(); + } + + @Test + void inactiveGuardCancelsPendingHttpFuture() throws Exception { + AtomicBoolean active = new AtomicBoolean(true); + CountDownLatch waiting = new CountDownLatch(1); + CompletableFuture response = new CompletableFuture<>(); + ExecutorService caller = Executors.newSingleThreadExecutor(); + try { + Future result = caller.submit(() -> NativePackageManager.awaitDownloadFuture( + response, + () -> { + waiting.countDown(); + return active.get(); + } + )); + assertTrue(waiting.await(2, TimeUnit.SECONDS)); + + active.set(false); + + ExecutionException failure = assertThrows(ExecutionException.class, + () -> result.get(2, TimeUnit.SECONDS)); + assertInstanceOf(CancellationException.class, failure.getCause()); + assertTrue(response.isCancelled()); + } finally { + caller.shutdownNow(); + } + } + + @Test + void lifecycleCancellationStopsBlockedReadAndAllowsLaterReads() throws Exception { + BlockingInputStream input = new BlockingInputStream(); + ExecutorService caller = Executors.newSingleThreadExecutor(); + try { + Future result = caller.submit(() -> NativePackageManager.readWithIdleTimeout( + input, + new byte[1], + () -> true + )); + assertTrue(input.started.await(2, TimeUnit.SECONDS)); + + NativePackageManager.cancelActiveDownloads(); + + ExecutionException failure = assertThrows(ExecutionException.class, + () -> result.get(2, TimeUnit.SECONDS)); + assertTrue(failure.getCause() instanceof CancellationException + || failure.getCause() instanceof IOException); + assertTrue(input.closed.get()); + assertEquals(1, NativePackageManager.readWithIdleTimeout( + new ByteArrayInputStream(new byte[]{42}), + new byte[1], + () -> true + )); + } finally { + input.close(); + caller.shutdownNow(); + } + } + + private static final class BlockingInputStream extends InputStream { + private final CountDownLatch started = new CountDownLatch(1); + private final CountDownLatch released = new CountDownLatch(1); + private final AtomicBoolean closed = new AtomicBoolean(); + + @Override + public int read() throws IOException { + byte[] value = new byte[1]; + return read(value, 0, 1) < 0 ? -1 : value[0] & 0xff; + } + + @Override + public int read(byte[] buffer, int offset, int length) throws IOException { + started.countDown(); + try { + released.await(); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw new IOException("interrupted", error); + } + if (closed.get()) throw new IOException("closed"); + return -1; + } + + @Override + public void close() { + closed.set(true); + released.countDown(); + } + } +} diff --git a/src/test/java/com/github/squi2rel/vp/network/VideoPacketsClientConfigTest.java b/src/test/java/com/github/squi2rel/vp/network/VideoPacketsClientConfigTest.java new file mode 100644 index 0000000..bac331f --- /dev/null +++ b/src/test/java/com/github/squi2rel/vp/network/VideoPacketsClientConfigTest.java @@ -0,0 +1,22 @@ +package com.github.squi2rel.vp.network; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +class VideoPacketsClientConfigTest { + @Test + void carriesTheReleased202TokenForA203Client() { + ByteBuf buf = Unpooled.wrappedBuffer(VideoPackets.clientConfig("2.0.3")); + try { + assertEquals(VideoPacketType.CONFIG, VideoPackets.readType(buf)); + assertEquals("2.0.2|vp5", ByteBufUtils.readString(buf, VideoProtocol.MAX_TOKEN_BYTES)); + assertFalse(buf.isReadable()); + } finally { + buf.release(); + } + } +} diff --git a/src/test/java/com/github/squi2rel/vp/network/VideoProtocolTest.java b/src/test/java/com/github/squi2rel/vp/network/VideoProtocolTest.java index 0f9a2ea..c90791f 100644 --- a/src/test/java/com/github/squi2rel/vp/network/VideoProtocolTest.java +++ b/src/test/java/com/github/squi2rel/vp/network/VideoProtocolTest.java @@ -2,19 +2,28 @@ import org.junit.jupiter.api.Test; +import java.nio.charset.StandardCharsets; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; class VideoProtocolTest { @Test void createsAndMatchesTheCurrentWireToken() { - assertEquals("2.0.1|vp5", VideoProtocol.token("2.0.1")); - assertTrue(VideoProtocol.compatible("2.0.1", "2.0.1|vp5")); - assertTrue(VideoProtocol.compatible("2.0.1", " 2.0.1|vp5")); - assertTrue(VideoProtocol.compatible("2.0.1", "2.0.1|vp5 ")); + assertEquals("2.0.3|vp5", VideoProtocol.token("2.0.3")); + assertTrue(VideoProtocol.compatible("2.0.3", "2.0.3|vp5")); + assertTrue(VideoProtocol.compatible("2.0.3", " 2.0.3|vp5")); + assertTrue(VideoProtocol.compatible("2.0.3", "2.0.3|vp5 ")); + } + + @Test + void advertisesTheReleased202TokenForThe203ClientHandshake() { + assertEquals("2.0.2|vp5", VideoProtocol.handshakeToken("2.0.3")); + assertTrue(VideoProtocol.compatible("2.0.2", VideoProtocol.handshakeToken("2.0.3"))); + assertEquals("2.0.4|vp5", VideoProtocol.handshakeToken("2.0.4")); } @Test @@ -33,6 +42,12 @@ void enforcesTheReleaseVersionMatrix() { new CompatibilityCase("2.0.1", "2.0.1|vp5", true), new CompatibilityCase("2.0.1", "2.0.2|vp5", true), new CompatibilityCase("2.0.2", "2.0.1|vp2", true), + new CompatibilityCase("2.0.3", "2.0.1|vp5", true), + new CompatibilityCase("2.0.1", "2.0.3|vp5", true), + new CompatibilityCase("2.0.3", "2.0.2|vp5", true), + new CompatibilityCase("2.0.2", "2.0.3|vp5", true), + new CompatibilityCase("2.0.3", "2.0.4|vp5", false), + new CompatibilityCase("2.0.4", "2.0.3|vp5", false), new CompatibilityCase("2.0.1", "2.0.10|vp5", false), new CompatibilityCase("2.0.1", "2.0.1|vp1", false), new CompatibilityCase("2.0.1", "2.0.1|vp5-extra", false), @@ -87,6 +102,17 @@ void allowsHandshakePacketsAfterClientRejection() { } } + @Test + void rejectsTokensThatCannotFitTheServerHandshakeField() { + assertEquals(16, VideoProtocol.MAX_TOKEN_BYTES); + String hotfixToken = VideoProtocol.token("2.0.3"); + assertEquals("2.0.3|vp5", hotfixToken); + assertTrue(hotfixToken.getBytes(StandardCharsets.UTF_8).length <= VideoProtocol.MAX_TOKEN_BYTES); + assertEquals("2.0.2-26.2|vp5", VideoProtocol.token("2.0.2-26.2")); + assertThrows(IllegalArgumentException.class, () -> VideoProtocol.token("2.0.2-26.2-long")); + assertThrows(IllegalArgumentException.class, () -> VideoProtocol.token("版本版本版本版本")); + } + private record CompatibilityCase(String localVersion, String remoteToken, boolean expected) { } } diff --git a/src/test/java/com/github/squi2rel/vp/provider/LocalPlaybackInfoTest.java b/src/test/java/com/github/squi2rel/vp/provider/LocalPlaybackInfoTest.java index e4f11e9..62097b2 100644 --- a/src/test/java/com/github/squi2rel/vp/provider/LocalPlaybackInfoTest.java +++ b/src/test/java/com/github/squi2rel/vp/provider/LocalPlaybackInfoTest.java @@ -6,6 +6,7 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; + class LocalPlaybackInfoTest { @Test void fallsBackToUnexpiredServerPathWhenLocalResolutionFails() { diff --git a/src/test/java/com/github/squi2rel/vp/provider/MediaAddressPolicyTest.java b/src/test/java/com/github/squi2rel/vp/provider/MediaAddressPolicyTest.java index bcc4463..46e946c 100644 --- a/src/test/java/com/github/squi2rel/vp/provider/MediaAddressPolicyTest.java +++ b/src/test/java/com/github/squi2rel/vp/provider/MediaAddressPolicyTest.java @@ -26,4 +26,56 @@ void blocksPrivateAndMetadataRanges() throws Exception { InetAddress.getByName("10.1.2.3") })); } + + @Test + void allowsRemoteDownloadHostWhenProxyOwnsDnsResolution() throws Exception { + assertTrue(MediaAddressPolicy.isAllowedForDownload( + "https://github.com/example/runtime.zip", + true, + ignored -> new InetAddress[]{InetAddress.getByName("198.18.0.156")} + )); + } + + @Test + void blocksProxySyntheticRangeWhenUriHostIsAnIpLiteral() { + MediaAddressPolicy.HostResolver resolver = host -> new InetAddress[]{InetAddress.getByName(host)}; + + assertFalse(MediaAddressPolicy.isAllowedForDownload("https://198.18.0.156/runtime.zip", true, resolver)); + assertFalse(MediaAddressPolicy.isAllowedForDownload("https://198.19.255.254/runtime.zip", true, resolver)); + } + + @Test + void doesNotApplyProxySyntheticExemptionToIpv6Literals() throws Exception { + assertFalse(MediaAddressPolicy.isAllowedForDownload( + "https://[2001:4860:4860::8888]/runtime.zip", + true, + ignored -> new InetAddress[]{InetAddress.getByName("198.18.0.156")} + )); + } + + @Test + void blocksAlternateIpLiteralFormsWhenProxyIsConfigured() { + MediaAddressPolicy.HostResolver resolver = host -> new InetAddress[]{InetAddress.getByName(host)}; + + assertFalse(MediaAddressPolicy.isAllowedForDownload("https://3323068417/runtime.zip", true, resolver)); + assertFalse(MediaAddressPolicy.isAllowedForDownload( + "https://[::ffff:198.18.0.1]/runtime.zip", + true, + resolver + )); + assertFalse(MediaAddressPolicy.isAllowedForDownload( + "https://[::ffff:c612:1]/runtime.zip", + true, + resolver + )); + } + + @Test + void blocksOtherPrivateAddressesWhenProxyIsConfigured() throws Exception { + assertFalse(MediaAddressPolicy.isAllowedForDownload( + "https://internal.example/runtime.zip", + true, + ignored -> new InetAddress[]{InetAddress.getByName("10.1.2.3")} + )); + } } diff --git a/src/test/java/com/github/squi2rel/vp/provider/YouTubeProviderTest.java b/src/test/java/com/github/squi2rel/vp/provider/YouTubeProviderTest.java index 55aca35..75ff94e 100644 --- a/src/test/java/com/github/squi2rel/vp/provider/YouTubeProviderTest.java +++ b/src/test/java/com/github/squi2rel/vp/provider/YouTubeProviderTest.java @@ -25,6 +25,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; + class YouTubeProviderTest { @AfterEach void resetConfiguration() { diff --git a/src/test/java/com/github/squi2rel/vp/render/ExternalTextureRegistryTest.java b/src/test/java/com/github/squi2rel/vp/render/ExternalTextureRegistryTest.java new file mode 100644 index 0000000..019b1d7 --- /dev/null +++ b/src/test/java/com/github/squi2rel/vp/render/ExternalTextureRegistryTest.java @@ -0,0 +1,68 @@ +package com.github.squi2rel.vp.render; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ExternalTextureRegistryTest { + @Test + void reusesActiveRegistrationForSameRawTexture() { + ExternalTextureRegistry registry = new ExternalTextureRegistry(); + + ExternalTextureRegistry.Acquisition first = registry.acquire(7); + ExternalTextureRegistry.Acquisition second = registry.acquire(7); + + assertTrue(first.created()); + assertFalse(second.created()); + assertEquals(first.registration(), second.registration()); + assertEquals(1, registry.size()); + } + + @Test + void releaseRemovesActiveRegistration() { + ExternalTextureRegistry registry = new ExternalTextureRegistry(); + ExternalTextureRegistry.Registration registration = registry.acquire(7).registration(); + + assertEquals(registration, registry.release(7).orElseThrow()); + assertEquals(0, registry.size()); + } + + @Test + void rawTextureReuseGetsNewGenerationAndPath() { + ExternalTextureRegistry registry = new ExternalTextureRegistry(); + ExternalTextureRegistry.Registration first = registry.acquire(7).registration(); + registry.release(7); + + ExternalTextureRegistry.Registration second = registry.acquire(7).registration(); + + assertNotEquals(first.generation(), second.generation()); + assertNotEquals(first.identifierPath(), second.identifierPath()); + } + + @Test + void clearReturnsAndRemovesAllActiveRegistrations() { + ExternalTextureRegistry registry = new ExternalTextureRegistry(); + ExternalTextureRegistry.Registration first = registry.acquire(7).registration(); + ExternalTextureRegistry.Registration second = registry.acquire(9).registration(); + + List cleared = registry.clear(); + + assertEquals(List.of(first, second), cleared); + assertEquals(0, registry.size()); + } + + @Test + void repeatedReleaseIsIdempotent() { + ExternalTextureRegistry registry = new ExternalTextureRegistry(); + registry.acquire(7); + + assertTrue(registry.release(7).isPresent()); + assertTrue(registry.release(7).isEmpty()); + assertEquals(0, registry.size()); + } +} diff --git a/src/test/java/com/github/squi2rel/vp/video/OrderedPlayAdmissionsTest.java b/src/test/java/com/github/squi2rel/vp/video/OrderedPlayAdmissionsTest.java index c368791..110b3ca 100644 --- a/src/test/java/com/github/squi2rel/vp/video/OrderedPlayAdmissionsTest.java +++ b/src/test/java/com/github/squi2rel/vp/video/OrderedPlayAdmissionsTest.java @@ -17,6 +17,7 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; + class OrderedPlayAdmissionsTest { @Test void commitsInReservationOrderWhenSecondCompletesFirst() { diff --git a/src/test/java/com/github/squi2rel/vp/video/PlaybackControllerTest.java b/src/test/java/com/github/squi2rel/vp/video/PlaybackControllerTest.java index dcd53de..48b9fb0 100644 --- a/src/test/java/com/github/squi2rel/vp/video/PlaybackControllerTest.java +++ b/src/test/java/com/github/squi2rel/vp/video/PlaybackControllerTest.java @@ -19,6 +19,7 @@ import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; + class PlaybackControllerTest { @Test void advancesFromFirstQueueItemToSecond() { diff --git a/src/test/java/com/github/squi2rel/vp/video/VideoAreaBoundsTest.java b/src/test/java/com/github/squi2rel/vp/video/VideoAreaBoundsTest.java index 8001f78..b2c2efb 100644 --- a/src/test/java/com/github/squi2rel/vp/video/VideoAreaBoundsTest.java +++ b/src/test/java/com/github/squi2rel/vp/video/VideoAreaBoundsTest.java @@ -1,21 +1,22 @@ package com.github.squi2rel.vp.video; -import net.minecraft.util.math.Vec3d; import org.joml.Vector3f; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import net.minecraft.world.phys.Vec3; + class VideoAreaBoundsTest { @Test void usesExclusiveMaximumForPlayerMembership() { VideoArea area = new VideoArea(new Vector3f(1, 2, 3), new Vector3f(5, 6, 7), "area", "world"); - assertTrue(area.inBounds(new Vec3d(1, 2, 3))); - assertTrue(area.inBounds(new Vec3d(4.999, 5.999, 6.999))); - assertFalse(area.inBounds(new Vec3d(5, 5, 6))); - assertFalse(area.inBounds(new Vec3d(4, 6, 6))); - assertFalse(area.inBounds(new Vec3d(4, 5, 7))); + assertTrue(area.inBounds(new Vec3(1, 2, 3))); + assertTrue(area.inBounds(new Vec3(4.999, 5.999, 6.999))); + assertFalse(area.inBounds(new Vec3(5, 5, 6))); + assertFalse(area.inBounds(new Vec3(4, 6, 6))); + assertFalse(area.inBounds(new Vec3(4, 5, 7))); } } diff --git a/src/test/java/com/github/squi2rel/vp/video/VideoScreenIdlePlayAntiRepeatTest.java b/src/test/java/com/github/squi2rel/vp/video/VideoScreenIdlePlayAntiRepeatTest.java index 1f44bfe..07740a6 100644 --- a/src/test/java/com/github/squi2rel/vp/video/VideoScreenIdlePlayAntiRepeatTest.java +++ b/src/test/java/com/github/squi2rel/vp/video/VideoScreenIdlePlayAntiRepeatTest.java @@ -11,6 +11,7 @@ import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertTrue; + class VideoScreenIdlePlayAntiRepeatTest { @Test void randomOrderNeverRepeatsTheLastPlayedEntryAcrossRebuilds() { diff --git a/src/test/java/com/github/squi2rel/vp/video/VideoScreenIdlePlayPriorityTest.java b/src/test/java/com/github/squi2rel/vp/video/VideoScreenIdlePlayPriorityTest.java index df8fd77..6deb970 100644 --- a/src/test/java/com/github/squi2rel/vp/video/VideoScreenIdlePlayPriorityTest.java +++ b/src/test/java/com/github/squi2rel/vp/video/VideoScreenIdlePlayPriorityTest.java @@ -11,6 +11,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; + class VideoScreenIdlePlayPriorityTest { @Test void sequentialPlaybackUsesDescendingPriorityAndStableInsertionOrder() { diff --git a/src/test/java/com/github/squi2rel/vp/video/VideoScreenPlaylistPersistenceTest.java b/src/test/java/com/github/squi2rel/vp/video/VideoScreenPlaylistPersistenceTest.java index a4f7bd5..6d218ba 100644 --- a/src/test/java/com/github/squi2rel/vp/video/VideoScreenPlaylistPersistenceTest.java +++ b/src/test/java/com/github/squi2rel/vp/video/VideoScreenPlaylistPersistenceTest.java @@ -10,6 +10,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; + class VideoScreenPlaylistPersistenceTest { @Test void restoresPersistedPlaylistOrderAndResumeProgress() {