Compare commits
125
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91a613d966 | ||
|
|
06b7b1e574 | ||
|
|
933b7cab2f | ||
|
|
885e25d07a | ||
|
|
421f9a3b2b | ||
|
|
e52cfb036e | ||
|
|
87a3a4ece3 | ||
|
|
b152e2bfab | ||
|
|
c9c97a45b7 | ||
|
|
cba4ba2799 | ||
|
|
ab9eea3620 | ||
|
|
96f2d011ca | ||
|
|
9ee55cd17d | ||
|
|
07ee7867da | ||
|
|
940d4f722c | ||
|
|
af56096162 | ||
|
|
0e12c1a132 | ||
|
|
ca20509cb1 | ||
|
|
58c93ff349 | ||
|
|
aab3b22290 | ||
|
|
6cf933741f | ||
|
|
9d17834227 | ||
|
|
7805508d4c | ||
|
|
72f6c2faf3 | ||
|
|
7035767828 | ||
|
|
13a2777c87 | ||
|
|
a4f9438d9e | ||
|
|
4445021d66 | ||
|
|
f6a3b76a0b | ||
|
|
42bb602ff5 | ||
|
|
4547448718 | ||
|
|
10bda7e6f4 | ||
|
|
f239140055 | ||
|
|
6f4b557f63 | ||
|
|
5118ce4e62 | ||
|
|
a705b7cf95 | ||
|
|
0d5d1ae4e6 | ||
|
|
b57547415b | ||
|
|
b60d889bf7 | ||
|
|
2f463c1eda | ||
|
|
d1e006ed29 | ||
|
|
ba58905c9c | ||
|
|
de221b9dae | ||
|
|
0913c5e710 | ||
|
|
41fd38d8d1 | ||
|
|
7ec880852f | ||
|
|
9213b7fb2e | ||
|
|
80f1367d9a | ||
|
|
f521a041a9 | ||
|
|
be9efbe6ee | ||
|
|
8f3c716ca7 | ||
|
|
3cdddfa86d | ||
|
|
b1d8edc72a | ||
|
|
bbc546e2a0 | ||
|
|
f621776cdd | ||
|
|
7ba9d9eadd | ||
|
|
ac4c3a7501 | ||
|
|
f95ebe2224 | ||
|
|
7ee2c51b81 | ||
|
|
192c90816b | ||
|
|
1a772e8c65 | ||
|
|
95bed4712c | ||
|
|
2c6ceb3cb0 | ||
|
|
3118c87c65 | ||
|
|
f945a85441 | ||
|
|
72ffbb4176 | ||
|
|
77dc5dbe06 | ||
|
|
0a1d6be7b7 | ||
|
|
bb3d03fc72 | ||
|
|
a0ddaf6766 | ||
|
|
0bd2adc3c6 | ||
|
|
77c3ef6ced | ||
|
|
a66ddb9a20 | ||
|
|
eef365b37e | ||
|
|
5cab603b6e | ||
|
|
6c5d07adad | ||
|
|
1935a7021d | ||
|
|
31c46c963d | ||
|
|
4bc6ffa0f0 | ||
|
|
b62659de62 | ||
|
|
9a27c27b9a | ||
|
|
74c647cc97 | ||
|
|
e2df8e25b1 | ||
|
|
9969dc60c5 | ||
|
|
6230b436ad | ||
|
|
3873459aaa | ||
|
|
4ee3857427 | ||
|
|
2617dd3630 | ||
|
|
e0425102c8 | ||
|
|
a470475c21 | ||
|
|
bcd73e6d15 | ||
|
|
7d6f21dc3a | ||
|
|
3eddfa23b7 | ||
|
|
d5fa232704 | ||
|
|
c84afb3155 | ||
|
|
401fef5774 | ||
|
|
9298fe48e1 | ||
|
|
7f51e0395c | ||
|
|
4f410fb8ad | ||
|
|
9bcf95287a | ||
|
|
5703377626 | ||
|
|
3adb3a67e9 | ||
|
|
7918b333e6 | ||
|
|
329b0e8f3a | ||
|
|
3b17e0a897 | ||
|
|
e10f1dac20 | ||
|
|
86c171545e | ||
|
|
c3a3881cfc | ||
|
|
7f04cdd6b1 | ||
|
|
27361c65fe | ||
|
|
ffa7afcbab | ||
|
|
9aa1367823 | ||
|
|
acc96b95c8 | ||
|
|
46502875c5 | ||
|
|
43b769923b | ||
|
|
fe3fc0413c | ||
|
|
fd13088726 | ||
|
|
195db2ee6d | ||
|
|
96f68e1017 | ||
|
|
24d3bbf4fe | ||
|
|
d60a923841 | ||
|
|
f9dd7a1e64 | ||
|
|
cbb83d4f15 | ||
|
|
bab863287d | ||
|
|
e4c479754b |
No files matched your search
@@ -1,11 +1,13 @@
|
||||
name: Check Translations
|
||||
name: Check Codes
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '**.java'
|
||||
- '**.properties'
|
||||
pull_request:
|
||||
paths:
|
||||
- '**.java'
|
||||
- '**.properties'
|
||||
|
||||
jobs:
|
||||
@@ -13,11 +15,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up JDK 11
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '11'
|
||||
java-version: '17'
|
||||
java-package: 'jdk+fx'
|
||||
- name: Check style test
|
||||
run: ./gradlew checkTranslations --no-daemon
|
||||
- name: Check Codes
|
||||
run: ./gradlew checkstyle checkTranslations --no-daemon --parallel
|
||||
@@ -1,25 +0,0 @@
|
||||
name: Check Style
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '**.java'
|
||||
pull_request:
|
||||
paths:
|
||||
- '**.java'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up JDK 11
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '11'
|
||||
java-package: 'jdk+fx'
|
||||
- name: Check style main
|
||||
run: ./gradlew checkstyleMain --no-daemon
|
||||
- name: Check style test
|
||||
run: ./gradlew checkstyleTest --no-daemon
|
||||
@@ -13,11 +13,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up JDK 11
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '11'
|
||||
java-version: '17'
|
||||
java-package: 'jdk+fx'
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew build --no-daemon
|
||||
|
||||
@@ -13,11 +13,13 @@ hs_err_pid*
|
||||
/externalgames
|
||||
NVIDIA
|
||||
minecraft-exported-crash-info*
|
||||
hmcl-exported-logs-*
|
||||
|
||||
# gradle build
|
||||
/build/
|
||||
/HMCL/build/
|
||||
/HMCLCore/build/
|
||||
/HMCLBoot/build/
|
||||
/HMCLTransformerDiscoveryService/build/
|
||||
/minecraft/libraries/HMCLTransformerDiscoveryService/build/
|
||||
/buildSrc/build/
|
||||
|
||||
+87
-46
@@ -37,15 +37,20 @@ val launcherExe = System.getenv("HMCL_LAUNCHER_EXE")
|
||||
|
||||
version = "$versionRoot.$buildNumber"
|
||||
|
||||
val embedResources by configurations.registering
|
||||
|
||||
dependencies {
|
||||
implementation(project(":HMCLCore"))
|
||||
implementation(project(":HMCLBoot"))
|
||||
implementation("libs:JFoenix")
|
||||
implementation(libs.twelvemonkeys.imageio.webp)
|
||||
implementation(libs.java.info)
|
||||
|
||||
if (launcherExe == null) {
|
||||
implementation("org.glavo.hmcl:HMCLauncher:3.6.0.4")
|
||||
implementation(libs.hmclauncher)
|
||||
}
|
||||
|
||||
embedResources(libs.authlib.injector)
|
||||
}
|
||||
|
||||
fun digest(algorithm: String, bytes: ByteArray): ByteArray = MessageDigest.getInstance(algorithm).digest(bytes)
|
||||
@@ -89,18 +94,64 @@ fun attachSignature(jar: File) {
|
||||
}
|
||||
}
|
||||
|
||||
val java11 = sourceSets.create("java11") {
|
||||
java {
|
||||
srcDir("src/main/java11")
|
||||
}
|
||||
}
|
||||
|
||||
tasks.getByName<JavaCompile>(java11.compileJavaTaskName) {
|
||||
options.compilerArgs.add("--add-exports=java.base/jdk.internal.loader=ALL-UNNAMED")
|
||||
tasks.withType<JavaCompile> {
|
||||
sourceCompatibility = "11"
|
||||
targetCompatibility = "11"
|
||||
}
|
||||
|
||||
tasks.checkstyleMain {
|
||||
// Third-party code is not checked
|
||||
exclude("**/org/jackhuang/hmcl/ui/image/apng/**")
|
||||
}
|
||||
|
||||
tasks.compileJava {
|
||||
options.compilerArgs.add("--add-exports=java.base/jdk.internal.loader=ALL-UNNAMED")
|
||||
}
|
||||
|
||||
val hmclProperties = buildList {
|
||||
add("hmcl.version" to project.version.toString())
|
||||
System.getenv("GITHUB_SHA")?.let {
|
||||
add("hmcl.version.hash" to it)
|
||||
}
|
||||
add("hmcl.version.type" to versionType)
|
||||
add("hmcl.microsoft.auth.id" to microsoftAuthId)
|
||||
add("hmcl.microsoft.auth.secret" to microsoftAuthSecret)
|
||||
add("hmcl.curseforge.apikey" to curseForgeApiKey)
|
||||
add("hmcl.authlib-injector.version" to libs.authlib.injector.get().version!!)
|
||||
}
|
||||
|
||||
val hmclPropertiesFile = layout.buildDirectory.file("hmcl.properties")
|
||||
val createPropertiesFile by tasks.registering {
|
||||
outputs.file(hmclPropertiesFile)
|
||||
hmclProperties.forEach { (k, v) -> inputs.property(k, v) }
|
||||
|
||||
doLast {
|
||||
val targetFile = hmclPropertiesFile.get().asFile
|
||||
targetFile.parentFile.mkdir()
|
||||
targetFile.bufferedWriter().use {
|
||||
for ((k, v) in hmclProperties) {
|
||||
it.write("$k=$v\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val addOpens = listOf(
|
||||
"java.base/java.lang",
|
||||
"java.base/java.lang.reflect",
|
||||
"java.base/jdk.internal.loader",
|
||||
"javafx.base/com.sun.javafx.binding",
|
||||
"javafx.base/com.sun.javafx.event",
|
||||
"javafx.base/com.sun.javafx.runtime",
|
||||
"javafx.graphics/javafx.css",
|
||||
"javafx.graphics/com.sun.javafx.stage",
|
||||
"javafx.graphics/com.sun.prism",
|
||||
"javafx.controls/com.sun.javafx.scene.control",
|
||||
"javafx.controls/com.sun.javafx.scene.control.behavior",
|
||||
"javafx.controls/javafx.scene.control.skin",
|
||||
"jdk.attach/sun.tools.attach",
|
||||
)
|
||||
|
||||
tasks.jar {
|
||||
enabled = false
|
||||
dependsOn(tasks["shadowJar"])
|
||||
@@ -109,6 +160,8 @@ tasks.jar {
|
||||
val jarPath = tasks.jar.get().archiveFile.get().asFile
|
||||
|
||||
tasks.shadowJar {
|
||||
dependsOn(createPropertiesFile)
|
||||
|
||||
archiveClassifier.set(null as String?)
|
||||
|
||||
exclude("**/package-info.class")
|
||||
@@ -118,7 +171,7 @@ tasks.shadowJar {
|
||||
exclude("META-INF/services/javax.imageio.spi.ImageInputStreamSpi")
|
||||
|
||||
listOf(
|
||||
"aix-*", "sunos-*", "openbsd-*", "dragonflybsd-*","freebsd-*", "linux-*", "darwin-*",
|
||||
"aix-*", "sunos-*", "openbsd-*", "dragonflybsd-*", "freebsd-*", "linux-*", "darwin-*",
|
||||
"*-ppc", "*-ppc64le", "*-s390x", "*-armel",
|
||||
).forEach { exclude("com/sun/jna/$it/**") }
|
||||
|
||||
@@ -126,41 +179,17 @@ tasks.shadowJar {
|
||||
exclude(dependency("com.google.code.gson:.*:.*"))
|
||||
exclude(dependency("net.java.dev.jna:jna:.*"))
|
||||
exclude(dependency("libs:JFoenix:.*"))
|
||||
exclude(project(":HMCLBoot"))
|
||||
}
|
||||
|
||||
manifest {
|
||||
attributes(
|
||||
"Created-By" to "Copyright(c) 2013-2025 huangyuhui.",
|
||||
"Main-Class" to "org.jackhuang.hmcl.Main",
|
||||
"Multi-Release" to "true",
|
||||
"Implementation-Version" to project.version,
|
||||
"Microsoft-Auth-Id" to microsoftAuthId,
|
||||
"Microsoft-Auth-Secret" to microsoftAuthSecret,
|
||||
"CurseForge-Api-Key" to curseForgeApiKey,
|
||||
"Build-Channel" to versionType,
|
||||
"Class-Path" to "pack200.jar",
|
||||
"Add-Opens" to listOf(
|
||||
"java.base/java.lang",
|
||||
"java.base/java.lang.reflect",
|
||||
"java.base/jdk.internal.loader",
|
||||
"javafx.base/com.sun.javafx.binding",
|
||||
"javafx.base/com.sun.javafx.event",
|
||||
"javafx.base/com.sun.javafx.runtime",
|
||||
"javafx.graphics/javafx.css",
|
||||
"javafx.graphics/com.sun.javafx.stage",
|
||||
"javafx.graphics/com.sun.prism",
|
||||
"javafx.controls/com.sun.javafx.scene.control",
|
||||
"javafx.controls/com.sun.javafx.scene.control.behavior",
|
||||
"javafx.controls/javafx.scene.control.skin",
|
||||
"jdk.attach/sun.tools.attach",
|
||||
).joinToString(" "),
|
||||
"Enable-Native-Access" to "ALL-UNNAMED"
|
||||
)
|
||||
|
||||
System.getenv("GITHUB_SHA")?.also {
|
||||
attributes("GitHub-SHA" to it)
|
||||
}
|
||||
}
|
||||
manifest.attributes(
|
||||
"Created-By" to "Copyright(c) 2013-2025 huangyuhui.",
|
||||
"Implementation-Version" to project.version.toString(),
|
||||
"Main-Class" to "org.jackhuang.hmcl.Main",
|
||||
"Multi-Release" to "true",
|
||||
"Add-Opens" to addOpens.joinToString(" "),
|
||||
"Enable-Native-Access" to "ALL-UNNAMED"
|
||||
)
|
||||
|
||||
if (launcherExe != null) {
|
||||
into("assets") {
|
||||
@@ -175,10 +204,12 @@ tasks.shadowJar {
|
||||
}
|
||||
|
||||
tasks.processResources {
|
||||
into("META-INF/versions/11") {
|
||||
from(sourceSets["java11"].output)
|
||||
dependsOn(createPropertiesFile)
|
||||
|
||||
into("assets/") {
|
||||
from(hmclPropertiesFile)
|
||||
from(embedResources)
|
||||
}
|
||||
dependsOn(tasks["java11Classes"])
|
||||
}
|
||||
|
||||
val makeExecutables by tasks.registering {
|
||||
@@ -263,6 +294,16 @@ fun parseToolOptions(options: String?): MutableList<String> {
|
||||
return result
|
||||
}
|
||||
|
||||
// For IntelliJ IDEA
|
||||
tasks.withType<JavaExec> {
|
||||
if (name != "run") {
|
||||
jvmArgs(addOpens.map { "--add-opens=$it=ALL-UNNAMED" })
|
||||
if (javaVersion >= JavaVersion.VERSION_24) {
|
||||
jvmArgs("--enable-native-access=ALL-UNNAMED")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register<JavaExec>("run") {
|
||||
dependsOn(tasks.jar)
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.4 KiB |
+49
-101
@@ -17,41 +17,26 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.control.Alert;
|
||||
import org.jackhuang.hmcl.util.FileSaver;
|
||||
import org.jackhuang.hmcl.ui.AwtUtils;
|
||||
import org.jackhuang.hmcl.util.ModuleHelper;
|
||||
import org.jackhuang.hmcl.util.SelfDependencyPatcher;
|
||||
import org.jackhuang.hmcl.ui.SwingUtils;
|
||||
import org.jackhuang.hmcl.util.SwingUtils;
|
||||
import org.jackhuang.hmcl.java.JavaRuntime;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
|
||||
import javax.net.ssl.HttpsURLConnection;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.TrustManagerFactory;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.invoke.MethodHandle;
|
||||
import java.lang.invoke.MethodHandles;
|
||||
import java.lang.invoke.MethodType;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.security.KeyManagementException;
|
||||
import java.security.KeyStore;
|
||||
import java.security.KeyStoreException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.util.Collections;
|
||||
import java.util.concurrent.CancellationException;
|
||||
|
||||
import static org.jackhuang.hmcl.util.Lang.thread;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public final class Main {
|
||||
public final class EntryPoint {
|
||||
|
||||
private Main() {
|
||||
private EntryPoint() {
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
@@ -62,11 +47,22 @@ public final class Main {
|
||||
createHMCLDirectories();
|
||||
LOG.start(Metadata.HMCL_CURRENT_DIRECTORY.resolve("logs"));
|
||||
|
||||
checkDirectoryPath();
|
||||
if ("true".equalsIgnoreCase(System.getenv("HMCL_FORCE_GPU")))
|
||||
System.getProperties().putIfAbsent("prism.forceGPU", "true");
|
||||
|
||||
if (JavaRuntime.CURRENT_VERSION < 9)
|
||||
// This environment check will take ~300ms
|
||||
thread(Main::fixLetsEncrypt, "CA Certificate Check", true);
|
||||
String animationFrameRate = System.getenv("HMCL_ANIMATION_FRAME_RATE");
|
||||
if (animationFrameRate != null) {
|
||||
try {
|
||||
if (Integer.parseInt(animationFrameRate) <= 0)
|
||||
throw new NumberFormatException(animationFrameRate);
|
||||
|
||||
System.getProperties().putIfAbsent("javafx.animation.pulse", animationFrameRate);
|
||||
} catch (NumberFormatException e) {
|
||||
LOG.warning("Invalid animation frame rate: " + animationFrameRate);
|
||||
}
|
||||
}
|
||||
|
||||
checkDirectoryPath();
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS)
|
||||
initIcon();
|
||||
@@ -97,6 +93,8 @@ public final class Main {
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
// Logger has not been started yet, so print directly to System.err
|
||||
System.err.println("Failed to create HMCL directory: " + Metadata.HMCL_CURRENT_DIRECTORY);
|
||||
e.printStackTrace(System.err);
|
||||
showErrorAndExit(i18n("fatal.create_hmcl_current_directory_failure", Metadata.HMCL_CURRENT_DIRECTORY));
|
||||
}
|
||||
@@ -112,16 +110,17 @@ public final class Main {
|
||||
}
|
||||
|
||||
private static void initIcon() {
|
||||
java.awt.Image image = java.awt.Toolkit.getDefaultToolkit().getImage(Main.class.getResource("/assets/img/icon-mac.png"));
|
||||
java.awt.Image image = java.awt.Toolkit.getDefaultToolkit().getImage(EntryPoint.class.getResource("/assets/img/icon-mac.png"));
|
||||
AwtUtils.setAppleIcon(image);
|
||||
}
|
||||
|
||||
private static void checkDirectoryPath() {
|
||||
String currentDirectory = new File("").getAbsolutePath();
|
||||
if (currentDirectory.contains("!")) {
|
||||
String currentDir = System.getProperty("user.dir", "");
|
||||
if (currentDir.contains("!")) {
|
||||
LOG.error("The current working path contains an exclamation mark: " + currentDir);
|
||||
// No Chinese translation because both Swing and JavaFX cannot render Chinese character properly when exclamation mark exists in the path.
|
||||
showErrorAndExit("Exclamation mark(!) is not allowed in the path where HMCL is in.\n"
|
||||
+ "The path is " + currentDirectory);
|
||||
+ "The path is " + currentDir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,10 +128,10 @@ public final class Main {
|
||||
try {
|
||||
SelfDependencyPatcher.patch();
|
||||
} catch (SelfDependencyPatcher.PatchException e) {
|
||||
LOG.error("unable to patch JVM", e);
|
||||
LOG.error("Unable to patch JVM", e);
|
||||
showErrorAndExit(i18n("fatal.javafx.missing"));
|
||||
} catch (SelfDependencyPatcher.IncompatibleVersionException e) {
|
||||
LOG.error("unable to patch JVM", e);
|
||||
LOG.error("Unable to patch JVM", e);
|
||||
showErrorAndExit(i18n("fatal.javafx.incompatible"));
|
||||
} catch (CancellationException e) {
|
||||
LOG.error("User cancels downloading JavaFX", e);
|
||||
@@ -149,7 +148,7 @@ public final class Main {
|
||||
Class.forName("javafx.stage.Stage"); // javafx.graphics
|
||||
Class.forName("javafx.scene.control.Skin"); // javafx.controls
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace(System.err);
|
||||
LOG.warning("JavaFX is incomplete or not found", e);
|
||||
showErrorAndExit(i18n("fatal.javafx.incomplete"));
|
||||
}
|
||||
}
|
||||
@@ -157,9 +156,20 @@ public final class Main {
|
||||
private static void addEnableNativeAccess() {
|
||||
if (JavaRuntime.CURRENT_VERSION > 21) {
|
||||
try {
|
||||
ModuleHelper.addEnableNativeAccess(Class.forName("javafx.stage.Stage")); // javafx.graphics
|
||||
// javafx.graphics
|
||||
Module module = Class.forName("javafx.stage.Stage").getModule();
|
||||
if (module.isNamed()) {
|
||||
try {
|
||||
MethodHandles.Lookup lookup = MethodHandles.privateLookupIn(Module.class, MethodHandles.lookup());
|
||||
MethodHandle implAddEnableNativeAccess = lookup.findVirtual(Module.class,
|
||||
"implAddEnableNativeAccess", MethodType.methodType(Module.class));
|
||||
Module ignored = (Module) implAddEnableNativeAccess.invokeExact(module);
|
||||
} catch (Throwable e) {
|
||||
e.printStackTrace(System.err);
|
||||
}
|
||||
}
|
||||
} catch (ClassNotFoundException e) {
|
||||
e.printStackTrace(System.err);
|
||||
LOG.error("Failed to add enable native access for JavaFX", e);
|
||||
showErrorAndExit(i18n("fatal.javafx.incomplete"));
|
||||
}
|
||||
}
|
||||
@@ -170,11 +180,11 @@ public final class Main {
|
||||
if (JavaRuntime.CURRENT_VERSION == 24 || JavaRuntime.CURRENT_VERSION == 25) {
|
||||
try {
|
||||
Class<?> clazz = Class.forName("sun.misc.Unsafe");
|
||||
Method trySetMemoryAccessWarned = clazz.getDeclaredMethod("trySetMemoryAccessWarned");
|
||||
trySetMemoryAccessWarned.setAccessible(true);
|
||||
trySetMemoryAccessWarned.invoke(null);
|
||||
boolean ignored = (boolean) MethodHandles.privateLookupIn(clazz, MethodHandles.lookup())
|
||||
.findStatic(clazz, "trySetMemoryAccessWarned", MethodType.methodType(boolean.class))
|
||||
.invokeExact();
|
||||
} catch (Throwable e) {
|
||||
e.printStackTrace(System.err);
|
||||
LOG.warning("Failed to enable unsafe memory access", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,70 +192,8 @@ public final class Main {
|
||||
/**
|
||||
* Indicates that a fatal error has occurred, and that the application cannot start.
|
||||
*/
|
||||
static void showErrorAndExit(String message) {
|
||||
System.err.println(message);
|
||||
System.err.println("A fatal error has occurred, forcibly exiting.");
|
||||
|
||||
try {
|
||||
if (Platform.isFxApplicationThread()) {
|
||||
new Alert(Alert.AlertType.ERROR, message).showAndWait();
|
||||
exit(1);
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
|
||||
private static void showErrorAndExit(String message) {
|
||||
SwingUtils.showErrorDialog(message);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates that potential issues have been detected, and that the application may not function properly (but it can still run).
|
||||
*/
|
||||
static void showWarningAndContinue(String message) {
|
||||
System.err.println(message);
|
||||
System.err.println("Potential issues have been detected.");
|
||||
|
||||
try {
|
||||
if (Platform.isFxApplicationThread()) {
|
||||
new Alert(Alert.AlertType.WARNING, message).showAndWait();
|
||||
return;
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
|
||||
SwingUtils.showWarningDialog(message);
|
||||
}
|
||||
|
||||
private static void fixLetsEncrypt() {
|
||||
try {
|
||||
KeyStore defaultKeyStore = KeyStore.getInstance(KeyStore.getDefaultType());
|
||||
Path ksPath = Paths.get(System.getProperty("java.home"), "lib", "security", "cacerts");
|
||||
|
||||
try (InputStream ksStream = Files.newInputStream(ksPath)) {
|
||||
defaultKeyStore.load(ksStream, "changeit".toCharArray());
|
||||
}
|
||||
|
||||
KeyStore letsEncryptKeyStore = KeyStore.getInstance(KeyStore.getDefaultType());
|
||||
try (InputStream letsEncryptFile = Main.class.getResourceAsStream("/assets/lekeystore.jks")) {
|
||||
letsEncryptKeyStore.load(letsEncryptFile, "supersecretpassword".toCharArray());
|
||||
}
|
||||
|
||||
KeyStore merged = KeyStore.getInstance(KeyStore.getDefaultType());
|
||||
merged.load(null, new char[0]);
|
||||
for (String alias : Collections.list(letsEncryptKeyStore.aliases()))
|
||||
merged.setCertificateEntry(alias, letsEncryptKeyStore.getCertificate(alias));
|
||||
for (String alias : Collections.list(defaultKeyStore.aliases()))
|
||||
merged.setCertificateEntry(alias, defaultKeyStore.getCertificate(alias));
|
||||
|
||||
TrustManagerFactory instance = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
|
||||
instance.init(merged);
|
||||
SSLContext tls = SSLContext.getInstance("TLS");
|
||||
tls.init(null, instance.getTrustManagers(), null);
|
||||
HttpsURLConnection.setDefaultSSLSocketFactory(tls.getSocketFactory());
|
||||
LOG.info("Added Lets Encrypt root certificates as additional trust");
|
||||
} catch (KeyStoreException | IOException | NoSuchAlgorithmException | CertificateException |
|
||||
KeyManagementException e) {
|
||||
LOG.error("Failed to load lets encrypt certificate. Expect problems", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ package org.jackhuang.hmcl;
|
||||
|
||||
import javafx.application.Application;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.value.ObservableBooleanValue;
|
||||
import javafx.scene.control.Alert;
|
||||
import javafx.scene.control.Alert.AlertType;
|
||||
import javafx.scene.control.ButtonType;
|
||||
@@ -27,6 +28,7 @@ import javafx.scene.input.DataFormat;
|
||||
import javafx.stage.Stage;
|
||||
import org.jackhuang.hmcl.setting.ConfigHolder;
|
||||
import org.jackhuang.hmcl.setting.SambaException;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.util.FileSaver;
|
||||
import org.jackhuang.hmcl.task.AsyncTaskExecutor;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
@@ -54,6 +56,8 @@ import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
|
||||
@@ -78,16 +82,20 @@ public final class Launcher extends Application {
|
||||
LOG.warning("Failed to get prism pipeline", e);
|
||||
}
|
||||
|
||||
LOG.info("Dark Mode: " + Optional.ofNullable(FXUtils.DARK_MODE).map(ObservableBooleanValue::get).orElse(false));
|
||||
LOG.info("Reduced Motion: " + Objects.requireNonNullElse(FXUtils.REDUCED_MOTION, false));
|
||||
|
||||
try {
|
||||
try {
|
||||
ConfigHolder.init();
|
||||
} catch (SambaException ignored) {
|
||||
Main.showWarningAndContinue(i18n("fatal.samba"));
|
||||
} catch (SambaException e) {
|
||||
showAlert(AlertType.WARNING, i18n("fatal.samba"));
|
||||
} catch (IOException e) {
|
||||
LOG.error("Failed to load config", e);
|
||||
checkConfigInTempDir();
|
||||
checkConfigOwner();
|
||||
Main.showErrorAndExit(i18n("fatal.config_loading_failure", ConfigHolder.configLocation().getParent()));
|
||||
showAlert(AlertType.ERROR, i18n("fatal.config_loading_failure", ConfigHolder.configLocation().getParent()));
|
||||
EntryPoint.exit(1);
|
||||
}
|
||||
|
||||
// https://lapcatsoftware.com/articles/app-translocation.html
|
||||
@@ -106,7 +114,7 @@ public final class Launcher extends Application {
|
||||
}
|
||||
|
||||
if (Metadata.HMCL_CURRENT_DIRECTORY.toString().indexOf('=') >= 0) {
|
||||
Main.showWarningAndContinue(i18n("fatal.illegal_char"));
|
||||
showAlert(AlertType.WARNING, i18n("fatal.illegal_char"));
|
||||
}
|
||||
|
||||
// runLater to ensure ConfigHolder.init() finished initialization
|
||||
@@ -169,7 +177,7 @@ public final class Launcher extends Application {
|
||||
private static void checkConfigInTempDir() {
|
||||
if (ConfigHolder.isNewlyCreated() && isConfigInTempDir()
|
||||
&& showAlert(AlertType.WARNING, i18n("fatal.config_in_temp_dir"), ButtonType.YES, ButtonType.NO) == ButtonType.NO) {
|
||||
Main.exit(0);
|
||||
EntryPoint.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,7 +217,7 @@ public final class Launcher extends Application {
|
||||
Clipboard.getSystemClipboard()
|
||||
.setContent(Collections.singletonMap(DataFormat.PLAIN_TEXT, command));
|
||||
}
|
||||
Main.exit(1);
|
||||
EntryPoint.exit(1);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -19,13 +19,10 @@ package org.jackhuang.hmcl;
|
||||
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.io.JarUtils;
|
||||
import org.jackhuang.hmcl.util.platform.Architecture;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.EnumSet;
|
||||
|
||||
/**
|
||||
* Stores metadata about this application.
|
||||
@@ -36,10 +33,7 @@ public final class Metadata {
|
||||
|
||||
public static final String NAME = "HMCL";
|
||||
public static final String FULL_NAME = "Hello Minecraft! Launcher";
|
||||
public static final String VERSION = System.getProperty("hmcl.version.override", JarUtils.getManifestAttribute("Implementation-Version", "@develop@"));
|
||||
|
||||
public static final int MINIMUM_REQUIRED_JAVA_VERSION = 8;
|
||||
public static final int MINIMUM_SUPPORTED_JAVA_VERSION = 11;
|
||||
public static final String VERSION = System.getProperty("hmcl.version.override", JarUtils.getAttribute("hmcl.version", "@develop@"));
|
||||
|
||||
public static final String TITLE = NAME + " " + VERSION;
|
||||
public static final String FULL_TITLE = FULL_NAME + " v" + VERSION;
|
||||
@@ -55,8 +49,8 @@ public final class Metadata {
|
||||
public static final String EULA_URL = DOCS_URL + "/eula/hmcl.html";
|
||||
public static final String GROUPS_URL = "https://www.bilibili.com/opus/905435541874409529";
|
||||
|
||||
public static final String BUILD_CHANNEL = JarUtils.getManifestAttribute("Build-Channel", "nightly");
|
||||
public static final String GITHUB_SHA = JarUtils.getManifestAttribute("GitHub-SHA", null);
|
||||
public static final String BUILD_CHANNEL = JarUtils.getAttribute("hmcl.version.type", "nightly");
|
||||
public static final String GITHUB_SHA = JarUtils.getAttribute("hmcl.version.hash", null);
|
||||
|
||||
public static final Path CURRENT_DIRECTORY = Paths.get(System.getProperty("user.dir")).toAbsolutePath().normalize();
|
||||
public static final Path MINECRAFT_DIRECTORY = OperatingSystem.getWorkingDirectory("minecraft");
|
||||
@@ -100,31 +94,4 @@ public final class Metadata {
|
||||
return !isStable() && !isDev();
|
||||
}
|
||||
|
||||
public static @Nullable String getSuggestedJavaDownloadLink() {
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX && Architecture.SYSTEM_ARCH == Architecture.LOONGARCH64_OW)
|
||||
return "https://www.loongnix.cn/zh/api/java/downloads-jdk21/index.html";
|
||||
else {
|
||||
EnumSet<Architecture> supportedArchitectures;
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS)
|
||||
supportedArchitectures = EnumSet.of(Architecture.X86_64, Architecture.X86, Architecture.ARM64);
|
||||
else if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX)
|
||||
supportedArchitectures = EnumSet.of(
|
||||
Architecture.X86_64, Architecture.X86,
|
||||
Architecture.ARM64, Architecture.ARM32,
|
||||
Architecture.RISCV64, Architecture.LOONGARCH64
|
||||
);
|
||||
else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS)
|
||||
supportedArchitectures = EnumSet.of(Architecture.X86_64, Architecture.ARM64);
|
||||
else
|
||||
supportedArchitectures = EnumSet.noneOf(Architecture.class);
|
||||
|
||||
if (supportedArchitectures.contains(Architecture.SYSTEM_ARCH))
|
||||
return String.format("https://docs.hmcl.net/downloads/%s/%s.html",
|
||||
OperatingSystem.CURRENT_OS.getCheckedName(),
|
||||
Architecture.SYSTEM_ARCH.getCheckedName()
|
||||
);
|
||||
else
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -158,6 +158,8 @@ public class HMCLGameRepository extends DefaultGameRepository {
|
||||
blackList.add("saves");
|
||||
|
||||
if (Files.exists(dstDir)) throw new IOException("Version exists");
|
||||
|
||||
Files.createDirectories(dstDir);
|
||||
FileUtils.copyDirectory(srcDir, dstDir, path -> Modpack.acceptFile(path, blackList, null));
|
||||
|
||||
Path fromJson = srcDir.resolve(srcId + ".json");
|
||||
@@ -170,7 +172,7 @@ public class HMCLGameRepository extends DefaultGameRepository {
|
||||
}
|
||||
Files.copy(fromJson, toJson);
|
||||
|
||||
FileUtils.writeText(toJson.toFile(), JsonUtils.GSON.toJson(fromVersion.setId(dstId)));
|
||||
JsonUtils.writeToJsonFile(toJson, fromVersion.setId(dstId));
|
||||
|
||||
VersionSetting oldVersionSetting = getVersionSetting(srcId).clone();
|
||||
GameDirectoryType originalGameDirType = oldVersionSetting.getGameDirType();
|
||||
@@ -194,7 +196,7 @@ public class HMCLGameRepository extends DefaultGameRepository {
|
||||
File file = getLocalVersionSettingFile(id);
|
||||
if (file.exists())
|
||||
try {
|
||||
VersionSetting versionSetting = GSON.fromJson(FileUtils.readText(file), VersionSetting.class);
|
||||
VersionSetting versionSetting = GSON.fromJson(Files.readString(file.toPath()), VersionSetting.class);
|
||||
initLocalVersionSetting(id, versionSetting);
|
||||
} catch (Exception ex) {
|
||||
// If [JsonParseException], [IOException] or [NullPointerException] happens, the json file is malformed and needed to be recreated.
|
||||
@@ -427,7 +429,7 @@ public class HMCLGameRepository extends DefaultGameRepository {
|
||||
File json = getModpackConfiguration(version);
|
||||
if (json.exists()) {
|
||||
try {
|
||||
String jsonText = FileUtils.readText(json);
|
||||
String jsonText = Files.readString(json.toPath());
|
||||
ModpackConfiguration<?> modpackConfiguration = JsonUtils.GSON.fromJson(jsonText, ModpackConfiguration.class);
|
||||
ModpackProvider provider = ModpackHelper.getProviderByType(modpackConfiguration.getType());
|
||||
if (provider != null) provider.injectLaunchOptions(jsonText, builder);
|
||||
|
||||
@@ -28,7 +28,6 @@ import org.jackhuang.hmcl.setting.Profile;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
@@ -66,7 +65,7 @@ public final class HMCLModpackInstallTask extends Task<Void> {
|
||||
ModpackConfiguration<Modpack> config = null;
|
||||
try {
|
||||
if (json.exists()) {
|
||||
config = JsonUtils.GSON.fromJson(FileUtils.readText(json), ModpackConfiguration.typeOf(Modpack.class));
|
||||
config = JsonUtils.fromJsonFile(json.toPath(), ModpackConfiguration.typeOf(Modpack.class));
|
||||
|
||||
if (!HMCLModpackProvider.INSTANCE.getName().equals(config.getType()))
|
||||
throw new IllegalArgumentException("Version " + name + " is not a HMCL modpack. Cannot update this version.");
|
||||
|
||||
@@ -53,12 +53,13 @@ import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.net.URL;
|
||||
import java.net.URI;
|
||||
import java.nio.file.AccessDeniedException;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.stream.Collectors;
|
||||
import java.lang.ref.WeakReference;
|
||||
|
||||
import static javafx.application.Platform.runLater;
|
||||
import static javafx.application.Platform.setImplicitExit;
|
||||
@@ -125,6 +126,9 @@ public final class LauncherHelper {
|
||||
}
|
||||
|
||||
private void launch0() {
|
||||
// https://github.com/HMCL-dev/HMCL/pull/4121
|
||||
PROCESSES.removeIf(it -> it.get() == null);
|
||||
|
||||
HMCLGameRepository repository = profile.getRepository();
|
||||
DefaultDependencyManager dependencyManager = profile.getDependency();
|
||||
AtomicReference<Version> version = new AtomicReference<>(MaintainTask.maintain(repository, repository.getResolvedVersion(selectedVersion)));
|
||||
@@ -156,8 +160,10 @@ public final class LauncherHelper {
|
||||
}),
|
||||
Task.composeAsync(() -> {
|
||||
Renderer renderer = setting.getRenderer();
|
||||
if (renderer != Renderer.DEFAULT && OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||
Library lib = NativePatcher.getMesaLoader(java);
|
||||
if (renderer != Renderer.DEFAULT
|
||||
&& OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS
|
||||
&& OperatingSystem.WINDOWS_VERSION != null) {
|
||||
Library lib = NativePatcher.getWindowsMesaLoader(java, renderer, OperatingSystem.WINDOWS_VERSION);
|
||||
if (lib == null)
|
||||
return null;
|
||||
File file = dependencyManager.getGameRepository().getLibraryFile(version.get(), lib);
|
||||
@@ -209,7 +215,7 @@ public final class LauncherHelper {
|
||||
}
|
||||
}).thenAcceptAsync(process -> { // process is LaunchTask's result
|
||||
if (scriptFile == null) {
|
||||
PROCESSES.add(process);
|
||||
PROCESSES.add(new WeakReference<>(process));
|
||||
if (launcherVisibility == LauncherVisibility.CLOSE)
|
||||
Launcher.stopApplication();
|
||||
else
|
||||
@@ -263,27 +269,27 @@ public final class LauncherHelper {
|
||||
if (ex.getCause() instanceof ResponseCodeException) {
|
||||
ResponseCodeException rce = (ResponseCodeException) ex.getCause();
|
||||
int responseCode = rce.getResponseCode();
|
||||
URL url = rce.getUrl();
|
||||
String uri = rce.getUri();
|
||||
if (responseCode == 404)
|
||||
message += i18n("download.code.404", url);
|
||||
message += i18n("download.code.404", uri);
|
||||
else
|
||||
message += i18n("download.failed", url, responseCode);
|
||||
message += i18n("download.failed", uri, responseCode);
|
||||
} else {
|
||||
message += StringUtils.getStackTrace(ex.getCause());
|
||||
}
|
||||
} else if (ex instanceof DownloadException) {
|
||||
URL url = ((DownloadException) ex).getUrl();
|
||||
URI uri = ((DownloadException) ex).getUri();
|
||||
if (ex.getCause() instanceof SocketTimeoutException) {
|
||||
message = i18n("install.failed.downloading.timeout", url);
|
||||
message = i18n("install.failed.downloading.timeout", uri);
|
||||
} else if (ex.getCause() instanceof ResponseCodeException) {
|
||||
ResponseCodeException responseCodeException = (ResponseCodeException) ex.getCause();
|
||||
if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) {
|
||||
message = i18n("download.code." + responseCodeException.getResponseCode(), url);
|
||||
message = i18n("download.code." + responseCodeException.getResponseCode(), uri);
|
||||
} else {
|
||||
message = i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(ex.getCause());
|
||||
message = i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(ex.getCause());
|
||||
}
|
||||
} else {
|
||||
message = i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(ex.getCause());
|
||||
message = i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(ex.getCause());
|
||||
}
|
||||
} else if (ex instanceof GameAssetIndexDownloadTask.GameAssetIndexMalformedException) {
|
||||
message = i18n("assets.index.malformed");
|
||||
@@ -294,11 +300,11 @@ public final class LauncherHelper {
|
||||
} else if (ex instanceof ResponseCodeException) {
|
||||
ResponseCodeException rce = (ResponseCodeException) ex;
|
||||
int responseCode = rce.getResponseCode();
|
||||
URL url = rce.getUrl();
|
||||
String uri = rce.getUri();
|
||||
if (responseCode == 404)
|
||||
message = i18n("download.code.404", url);
|
||||
message = i18n("download.code.404", uri);
|
||||
else
|
||||
message = i18n("download.failed", url, responseCode);
|
||||
message = i18n("download.failed", uri, responseCode);
|
||||
} else if (ex instanceof CommandTooLongException) {
|
||||
message = i18n("launch.failed.command_too_long");
|
||||
} else if (ex instanceof ExecutionPolicyLimitException) {
|
||||
@@ -896,10 +902,10 @@ public final class LauncherHelper {
|
||||
|
||||
}
|
||||
|
||||
public static final Queue<ManagedProcess> PROCESSES = new ConcurrentLinkedQueue<>();
|
||||
public static final Queue<WeakReference<ManagedProcess>> PROCESSES = new ConcurrentLinkedQueue<>();
|
||||
|
||||
public static void stopManagedProcesses() {
|
||||
while (!PROCESSES.isEmpty())
|
||||
Optional.ofNullable(PROCESSES.poll()).ifPresent(ManagedProcess::stop);
|
||||
Optional.ofNullable(PROCESSES.poll()).map(WeakReference::get).ifPresent(ManagedProcess::stop);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.mod.LocalModFile;
|
||||
import org.jackhuang.hmcl.mod.RemoteMod;
|
||||
import org.jackhuang.hmcl.mod.RemoteModRepository;
|
||||
@@ -41,9 +42,9 @@ public abstract class LocalizedRemoteModRepository implements RemoteModRepositor
|
||||
protected abstract SortType getBackedRemoteModRepositorySortOrder();
|
||||
|
||||
@Override
|
||||
public SearchResult search(String gameVersion, Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException {
|
||||
public SearchResult search(DownloadProvider downloadProvider, String gameVersion, Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException {
|
||||
if (!StringUtils.containsChinese(searchFilter)) {
|
||||
return getBackedRemoteModRepository().search(gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
|
||||
return getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
|
||||
}
|
||||
|
||||
Set<String> englishSearchFiltersSet = new HashSet<>(INITIAL_CAPACITY);
|
||||
@@ -65,7 +66,7 @@ public abstract class LocalizedRemoteModRepository implements RemoteModRepositor
|
||||
RemoteMod[] searchResultArray = new RemoteMod[pageSize];
|
||||
int totalPages, chineseIndex = 0, englishIndex = pageSize - 1;
|
||||
{
|
||||
SearchResult searchResult = getBackedRemoteModRepository().search(gameVersion, category, pageOffset, pageSize, String.join(" ", englishSearchFiltersSet), getBackedRemoteModRepositorySortOrder(), sortOrder);
|
||||
SearchResult searchResult = getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, String.join(" ", englishSearchFiltersSet), getBackedRemoteModRepositorySortOrder(), sortOrder);
|
||||
for (Iterator<RemoteMod> iterator = searchResult.getUnsortedResults().iterator(); iterator.hasNext(); ) {
|
||||
if (chineseIndex > englishIndex) {
|
||||
LOG.warning("Too many search results! Are the backed remote mod repository broken? Or are the API broken?");
|
||||
|
||||
@@ -50,10 +50,7 @@ import java.nio.file.FileSystem;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Arrays;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.*;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.jackhuang.hmcl.util.Lang.mapOf;
|
||||
@@ -140,7 +137,7 @@ public final class ModpackHelper {
|
||||
throw new FileNotFoundException(file.getPath());
|
||||
else
|
||||
try {
|
||||
return JsonUtils.GSON.fromJson(FileUtils.readText(file), ModpackConfiguration.class);
|
||||
return JsonUtils.fromJsonFile(file.toPath(), ModpackConfiguration.class);
|
||||
} catch (JsonParseException e) {
|
||||
throw new IOException("Malformed modpack configuration");
|
||||
}
|
||||
@@ -210,14 +207,17 @@ public final class ModpackHelper {
|
||||
if (modpack.getManifest() instanceof MultiMCInstanceConfiguration)
|
||||
return modpack.getInstallTask(profile.getDependency(), zipFile, name)
|
||||
.whenComplete(Schedulers.defaultScheduler(), success, failure)
|
||||
.thenComposeAsync(createMultiMCPostInstallTask(profile, (MultiMCInstanceConfiguration) modpack.getManifest(), name));
|
||||
.thenComposeAsync(createMultiMCPostInstallTask(profile, (MultiMCInstanceConfiguration) modpack.getManifest(), name))
|
||||
.withStagesHint(List.of("hmcl.modpack", "hmcl.modpack.download"));
|
||||
else if (modpack.getManifest() instanceof McbbsModpackManifest)
|
||||
return modpack.getInstallTask(profile.getDependency(), zipFile, name)
|
||||
.whenComplete(Schedulers.defaultScheduler(), success, failure)
|
||||
.thenComposeAsync(createMcbbsPostInstallTask(profile, (McbbsModpackManifest) modpack.getManifest(), name));
|
||||
.thenComposeAsync(createMcbbsPostInstallTask(profile, (McbbsModpackManifest) modpack.getManifest(), name))
|
||||
.withStagesHint(List.of("hmcl.modpack", "hmcl.modpack.download"));
|
||||
else
|
||||
return modpack.getInstallTask(profile.getDependency(), zipFile, name)
|
||||
.whenComplete(Schedulers.javafx(), success, failure);
|
||||
.whenComplete(Schedulers.javafx(), success, failure)
|
||||
.withStagesHint(List.of("hmcl.modpack", "hmcl.modpack.download"));
|
||||
}
|
||||
|
||||
public static Task<Void> getUpdateTask(Profile profile, ServerModpackManifest manifest, Charset charset, String name, ModpackConfiguration<?> configuration) throws UnsupportedModpackException {
|
||||
|
||||
@@ -158,13 +158,13 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
|
||||
@Override
|
||||
public String getClientId() {
|
||||
return System.getProperty("hmcl.microsoft.auth.id",
|
||||
JarUtils.getManifestAttribute("Microsoft-Auth-Id", ""));
|
||||
JarUtils.getAttribute("hmcl.microsoft.auth.id", ""));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getClientSecret() {
|
||||
return System.getProperty("hmcl.microsoft.auth.secret",
|
||||
JarUtils.getManifestAttribute("Microsoft-Auth-Secret", ""));
|
||||
JarUtils.getAttribute("hmcl.microsoft.auth.secret", ""));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -45,7 +45,6 @@ import org.jackhuang.hmcl.util.javafx.BindingMapping;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.net.URL;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
@@ -110,7 +109,7 @@ public final class TexturesLoader {
|
||||
if (!Files.isRegularFile(file)) {
|
||||
// download it
|
||||
try {
|
||||
new FileDownloadTask(new URL(texture.getUrl()), file.toFile()).run();
|
||||
new FileDownloadTask(texture.getUrl(), file).run();
|
||||
LOG.info("Texture downloaded: " + texture.getUrl());
|
||||
} catch (Exception e) {
|
||||
if (Files.isRegularFile(file)) {
|
||||
|
||||
@@ -30,7 +30,6 @@ import org.jackhuang.hmcl.util.platform.Platform;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
|
||||
@@ -116,11 +115,7 @@ public final class HMCLJavaRepository implements JavaRepository {
|
||||
}
|
||||
|
||||
if (Files.isDirectory(javaDir)) {
|
||||
JavaManifest manifest;
|
||||
try (InputStream input = Files.newInputStream(file)) {
|
||||
manifest = JsonUtils.fromJsonFully(input, JavaManifest.class);
|
||||
}
|
||||
|
||||
JavaManifest manifest = JsonUtils.fromJsonFile(file, JavaManifest.class);
|
||||
list.add(JavaRuntime.of(executable, manifest.getInfo(), isManaged));
|
||||
}
|
||||
}
|
||||
@@ -191,7 +186,7 @@ public final class HMCLJavaRepository implements JavaRepository {
|
||||
});
|
||||
|
||||
JavaManifest manifest = new JavaManifest(info, update, files);
|
||||
FileUtils.writeText(getManifestFile(platform, gameJavaVersion), JsonUtils.GSON.toJson(manifest));
|
||||
JsonUtils.writeToJsonFile(getManifestFile(platform, gameJavaVersion), manifest);
|
||||
return JavaRuntime.of(executable, info, true);
|
||||
});
|
||||
}
|
||||
@@ -203,7 +198,7 @@ public final class HMCLJavaRepository implements JavaRepository {
|
||||
throw new IOException("Platform is mismatch: expected " + platform + " but got " + result.getInfo().getPlatform());
|
||||
|
||||
Path executable = javaDir.resolve("bin").resolve(platform.getOperatingSystem().getJavaExecutable()).toRealPath();
|
||||
FileUtils.writeText(getManifestFile(platform, name), JsonUtils.GSON.toJson(result));
|
||||
JsonUtils.writeToJsonFile(getManifestFile(platform, name), result);
|
||||
return JavaRuntime.of(executable, result.getInfo(), true);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ public final class JavaManager {
|
||||
}
|
||||
|
||||
public static final HMCLJavaRepository REPOSITORY = new HMCLJavaRepository(Metadata.HMCL_GLOBAL_DIRECTORY.resolve("java"));
|
||||
public static final HMCLJavaRepository LOCAL_REPOSITORY = new HMCLJavaRepository(Metadata.HMCL_CURRENT_DIRECTORY.resolve("java"));
|
||||
|
||||
public static String getMojangJavaPlatform(Platform platform) {
|
||||
if (platform.getOperatingSystem() == OperatingSystem.WINDOWS) {
|
||||
@@ -411,8 +412,15 @@ public final class JavaManager {
|
||||
if (System.getenv("PATH") != null) {
|
||||
String[] paths = System.getenv("PATH").split(File.pathSeparator);
|
||||
for (String path : paths) {
|
||||
// https://github.com/HMCL-dev/HMCL/issues/4079
|
||||
// https://github.com/Meloong-Git/PCL/issues/4261
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && path.toLowerCase(Locale.ROOT)
|
||||
.contains("\\common files\\oracle\\java\\")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
tryAddJavaExecutable(javaRuntimes, Paths.get(path, OperatingSystem.CURRENT_OS.getJavaExecutable()));
|
||||
tryAddJavaExecutable(javaRuntimes, Path.of(path, OperatingSystem.CURRENT_OS.getJavaExecutable()));
|
||||
} catch (InvalidPathException ignored) {
|
||||
}
|
||||
}
|
||||
@@ -480,12 +488,15 @@ public final class JavaManager {
|
||||
try {
|
||||
info = JavaInfo.fromReleaseFile(releaseFile);
|
||||
} catch (IOException e) {
|
||||
try {
|
||||
info = JavaInfoUtils.fromExecutable(executable, false);
|
||||
} catch (IOException e2) {
|
||||
e2.addSuppressed(e);
|
||||
LOG.warning("Failed to lookup Java executable at " + executable, e2);
|
||||
}
|
||||
LOG.warning("Failed to read release file " + releaseFile, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (info == null) {
|
||||
try {
|
||||
info = JavaInfoUtils.fromExecutable(executable, false);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to lookup Java executable at " + executable, e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -561,6 +572,10 @@ public final class JavaManager {
|
||||
for (JavaRuntime java : REPOSITORY.getAllJava(platform)) {
|
||||
javaRuntimes.put(java.getBinary(), java);
|
||||
}
|
||||
|
||||
for (JavaRuntime java : LOCAL_REPOSITORY.getAllJava(platform)) {
|
||||
javaRuntimes.put(java.getBinary(), java);
|
||||
}
|
||||
}
|
||||
|
||||
private static void searchAllOfficialJava(Map<Path, JavaRuntime> javaRuntimes, Path directory, boolean verify) {
|
||||
|
||||
@@ -36,6 +36,7 @@ import org.jackhuang.hmcl.auth.yggdrasil.RemoteAuthenticationException;
|
||||
import org.jackhuang.hmcl.game.OAuthServer;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.util.FileSaver;
|
||||
import org.jackhuang.hmcl.util.io.JarUtils;
|
||||
import org.jackhuang.hmcl.util.skin.InvalidSkinException;
|
||||
|
||||
import javax.net.ssl.SSLException;
|
||||
@@ -68,18 +69,6 @@ public final class Accounts {
|
||||
|
||||
private static final AuthlibInjectorArtifactProvider AUTHLIB_INJECTOR_DOWNLOADER = createAuthlibInjectorArtifactProvider();
|
||||
|
||||
private static void triggerAuthlibInjectorUpdateCheck() {
|
||||
if (AUTHLIB_INJECTOR_DOWNLOADER instanceof AuthlibInjectorDownloader) {
|
||||
Schedulers.io().execute(() -> {
|
||||
try {
|
||||
((AuthlibInjectorDownloader) AUTHLIB_INJECTOR_DOWNLOADER).checkUpdate();
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to check update for authlib-injector", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public static final OAuthServer.Factory OAUTH_CALLBACK = new OAuthServer.Factory();
|
||||
|
||||
public static final OfflineAccountFactory FACTORY_OFFLINE = new OfflineAccountFactory(AUTHLIB_INJECTOR_DOWNLOADER);
|
||||
@@ -337,8 +326,6 @@ public final class Accounts {
|
||||
});
|
||||
}
|
||||
|
||||
triggerAuthlibInjectorUpdateCheck();
|
||||
|
||||
for (AuthlibInjectorServer server : config().getAuthlibInjectorServers()) {
|
||||
if (selected instanceof AuthlibInjectorAccount && ((AuthlibInjectorAccount) selected).getServer() == server)
|
||||
continue;
|
||||
@@ -346,7 +333,7 @@ public final class Accounts {
|
||||
try {
|
||||
server.fetchMetadataResponse();
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to fetch authlib-injector server metdata: " + server, e);
|
||||
LOG.warning("Failed to fetch authlib-injector server metadata: " + server, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -371,24 +358,18 @@ public final class Accounts {
|
||||
// ==== authlib-injector ====
|
||||
private static AuthlibInjectorArtifactProvider createAuthlibInjectorArtifactProvider() {
|
||||
String authlibinjectorLocation = System.getProperty("hmcl.authlibinjector.location");
|
||||
if (authlibinjectorLocation == null) {
|
||||
return new AuthlibInjectorDownloader(
|
||||
Metadata.DEPENDENCIES_DIRECTORY.resolve("universal").resolve("authlib-injector.jar"),
|
||||
DownloadProviders::getDownloadProvider) {
|
||||
@Override
|
||||
public Optional<AuthlibInjectorArtifactInfo> getArtifactInfoImmediately() {
|
||||
Optional<AuthlibInjectorArtifactInfo> local = super.getArtifactInfoImmediately();
|
||||
if (local.isPresent()) {
|
||||
return local;
|
||||
}
|
||||
// search authlib-injector.jar in current directory, it's used as a fallback
|
||||
return parseArtifact(Metadata.CURRENT_DIRECTORY.resolve("authlib-injector.jar"));
|
||||
}
|
||||
};
|
||||
} else {
|
||||
if (authlibinjectorLocation != null) {
|
||||
LOG.info("Using specified authlib-injector: " + authlibinjectorLocation);
|
||||
return new SimpleAuthlibInjectorArtifactProvider(Paths.get(authlibinjectorLocation));
|
||||
}
|
||||
|
||||
String authlibInjectorVersion = JarUtils.getAttribute("hmcl.authlib-injector.version", null);
|
||||
if (authlibInjectorVersion == null)
|
||||
throw new AssertionError("Missing hmcl.authlib-injector.version");
|
||||
|
||||
String authlibInjectorFileName = "authlib-injector-" + authlibInjectorVersion + ".jar";
|
||||
return new AuthlibInjectorExtractor(Accounts.class.getResource("/assets/" + authlibInjectorFileName),
|
||||
Metadata.DEPENDENCIES_DIRECTORY.resolve("universal").resolve(authlibInjectorFileName));
|
||||
}
|
||||
|
||||
private static AuthlibInjectorServer getOrCreateAuthlibInjectorServer(String url) {
|
||||
|
||||
@@ -24,7 +24,6 @@ import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.gson.TolerableValidationException;
|
||||
import org.jackhuang.hmcl.util.gson.Validation;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.JarUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -73,8 +72,7 @@ public final class AuthlibInjectorServers implements Validation {
|
||||
if (ConfigHolder.isNewlyCreated() && Files.exists(configLocation)) {
|
||||
AuthlibInjectorServers configInstance;
|
||||
try {
|
||||
String content = FileUtils.readText(configLocation);
|
||||
configInstance = JsonUtils.GSON.fromJson(content, AuthlibInjectorServers.class);
|
||||
configInstance = JsonUtils.fromJsonFile(configLocation, AuthlibInjectorServers.class);
|
||||
} catch (IOException | JsonParseException e) {
|
||||
LOG.warning("Malformed authlib-injectors.json", e);
|
||||
return;
|
||||
|
||||
@@ -29,6 +29,7 @@ import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.collections.ObservableMap;
|
||||
import javafx.collections.ObservableSet;
|
||||
import javafx.scene.paint.Paint;
|
||||
import org.hildan.fxgson.creators.ObservableListCreator;
|
||||
import org.hildan.fxgson.creators.ObservableMapCreator;
|
||||
import org.hildan.fxgson.creators.ObservableSetCreator;
|
||||
@@ -37,6 +38,7 @@ import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
|
||||
import org.jackhuang.hmcl.util.gson.EnumOrdinalDeserializer;
|
||||
import org.jackhuang.hmcl.util.gson.FileTypeAdapter;
|
||||
import org.jackhuang.hmcl.util.gson.PaintAdapter;
|
||||
import org.jackhuang.hmcl.util.i18n.Locales;
|
||||
import org.jackhuang.hmcl.util.i18n.Locales.SupportedLocale;
|
||||
import org.jackhuang.hmcl.util.javafx.ObservableHelper;
|
||||
@@ -60,6 +62,7 @@ public final class Config implements Observable {
|
||||
.registerTypeAdapterFactory(new JavaFxPropertyTypeAdapterFactory(true, true))
|
||||
.registerTypeAdapter(EnumBackgroundImage.class, new EnumOrdinalDeserializer<>(EnumBackgroundImage.class)) // backward compatibility for backgroundType
|
||||
.registerTypeAdapter(Proxy.Type.class, new EnumOrdinalDeserializer<>(Proxy.Type.class)) // backward compatibility for hasProxy
|
||||
.registerTypeAdapter(Paint.class, new PaintAdapter())
|
||||
.setPrettyPrinting()
|
||||
.setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE)
|
||||
.create();
|
||||
@@ -87,6 +90,12 @@ public final class Config implements Observable {
|
||||
@SerializedName("bgurl")
|
||||
private StringProperty backgroundImageUrl = new SimpleStringProperty();
|
||||
|
||||
@SerializedName("bgImageOpacity")
|
||||
private IntegerProperty backgroundImageOpacity = new SimpleIntegerProperty(100);
|
||||
|
||||
@SerializedName("bgpaint")
|
||||
private ObjectProperty<Paint> backgroundPaint = new SimpleObjectProperty<>();
|
||||
|
||||
@SerializedName("commonDirType")
|
||||
private ObjectProperty<EnumCommonDirectory> commonDirType = new SimpleObjectProperty<>(EnumCommonDirectory.DEFAULT);
|
||||
|
||||
@@ -172,7 +181,7 @@ public final class Config implements Observable {
|
||||
private BooleanProperty titleTransparent = new SimpleBooleanProperty(false);
|
||||
|
||||
@SerializedName("authlibInjectorServers")
|
||||
private ObservableList<AuthlibInjectorServer> authlibInjectorServers = FXCollections.observableArrayList(server -> new Observable[] { server });
|
||||
private ObservableList<AuthlibInjectorServer> authlibInjectorServers = FXCollections.observableArrayList(server -> new Observable[]{server});
|
||||
|
||||
@SerializedName("addedLittleSkin")
|
||||
private BooleanProperty addedLittleSkin = new SimpleBooleanProperty(false);
|
||||
@@ -274,6 +283,30 @@ public final class Config implements Observable {
|
||||
this.backgroundImageUrl.set(backgroundImageUrl);
|
||||
}
|
||||
|
||||
public Paint getBackgroundPaint() {
|
||||
return backgroundPaint.get();
|
||||
}
|
||||
|
||||
public ObjectProperty<Paint> backgroundPaintProperty() {
|
||||
return backgroundPaint;
|
||||
}
|
||||
|
||||
public void setBackgroundPaint(Paint backgroundPaint) {
|
||||
this.backgroundPaint.set(backgroundPaint);
|
||||
}
|
||||
|
||||
public int getBackgroundImageOpacity() {
|
||||
return backgroundImageOpacity.get();
|
||||
}
|
||||
|
||||
public void setBackgroundImageOpacity(int backgroundImageOpacity) {
|
||||
this.backgroundImageOpacity.set(backgroundImageOpacity);
|
||||
}
|
||||
|
||||
public IntegerProperty backgroundImageOpacityProperty() {
|
||||
return backgroundImageOpacity;
|
||||
}
|
||||
|
||||
public EnumCommonDirectory getCommonDirType() {
|
||||
return commonDirType.get();
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ public final class ConfigHolder {
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS
|
||||
&& configLocation.getFileSystem() == FileSystems.getDefault()
|
||||
&& configLocation.toFile().canWrite()) {
|
||||
LOG.warning("Config at " + configLocation + " is not writable, but it seems to be a Samba share or OpenJDK bug");
|
||||
// There are some serious problems with the implementation of Samba or OpenJDK
|
||||
throw new SambaException();
|
||||
} else {
|
||||
@@ -157,7 +158,7 @@ public final class ConfigHolder {
|
||||
LOG.warning("Failed to get owner");
|
||||
}
|
||||
try {
|
||||
String content = FileUtils.readText(configLocation);
|
||||
String content = Files.readString(configLocation);
|
||||
Config deserialized = Config.fromJson(content);
|
||||
if (deserialized == null) {
|
||||
LOG.info("Config is empty");
|
||||
@@ -179,7 +180,7 @@ public final class ConfigHolder {
|
||||
private static GlobalConfig loadGlobalConfig() throws IOException {
|
||||
if (Files.exists(GLOBAL_CONFIG_PATH)) {
|
||||
try {
|
||||
String content = FileUtils.readText(GLOBAL_CONFIG_PATH);
|
||||
String content = Files.readString(GLOBAL_CONFIG_PATH);
|
||||
GlobalConfig deserialized = GlobalConfig.fromJson(content);
|
||||
if (deserialized == null) {
|
||||
LOG.info("Config is empty");
|
||||
|
||||
@@ -29,7 +29,7 @@ import org.jackhuang.hmcl.util.io.ResponseCodeException;
|
||||
import javax.net.ssl.SSLHandshakeException;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.net.URL;
|
||||
import java.net.URI;
|
||||
import java.nio.file.AccessDeniedException;
|
||||
import java.util.Arrays;
|
||||
import java.util.Map;
|
||||
@@ -137,26 +137,26 @@ public final class DownloadProviders {
|
||||
|
||||
public static String localizeErrorMessage(Throwable exception) {
|
||||
if (exception instanceof DownloadException) {
|
||||
URL url = ((DownloadException) exception).getUrl();
|
||||
URI uri = ((DownloadException) exception).getUri();
|
||||
if (exception.getCause() instanceof SocketTimeoutException) {
|
||||
return i18n("install.failed.downloading.timeout", url);
|
||||
return i18n("install.failed.downloading.timeout", uri);
|
||||
} else if (exception.getCause() instanceof ResponseCodeException) {
|
||||
ResponseCodeException responseCodeException = (ResponseCodeException) exception.getCause();
|
||||
if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) {
|
||||
return i18n("download.code." + responseCodeException.getResponseCode(), url);
|
||||
return i18n("download.code." + responseCodeException.getResponseCode(), uri);
|
||||
} else {
|
||||
return i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(exception.getCause());
|
||||
return i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause());
|
||||
}
|
||||
} else if (exception.getCause() instanceof FileNotFoundException) {
|
||||
return i18n("download.code.404", url);
|
||||
return i18n("download.code.404", uri);
|
||||
} else if (exception.getCause() instanceof AccessDeniedException) {
|
||||
return i18n("install.failed.downloading.detail", url) + "\n" + i18n("exception.access_denied", ((AccessDeniedException) exception.getCause()).getFile());
|
||||
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.access_denied", ((AccessDeniedException) exception.getCause()).getFile());
|
||||
} else if (exception.getCause() instanceof ArtifactMalformedException) {
|
||||
return i18n("install.failed.downloading.detail", url) + "\n" + i18n("exception.artifact_malformed");
|
||||
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.artifact_malformed");
|
||||
} else if (exception.getCause() instanceof SSLHandshakeException) {
|
||||
return i18n("install.failed.downloading.detail", url) + "\n" + i18n("exception.ssl_handshake");
|
||||
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.ssl_handshake");
|
||||
} else {
|
||||
return i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(exception.getCause());
|
||||
return i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause());
|
||||
}
|
||||
} else if (exception instanceof ArtifactMalformedException) {
|
||||
return i18n("exception.artifact_malformed");
|
||||
|
||||
@@ -22,5 +22,6 @@ public enum EnumBackgroundImage {
|
||||
CUSTOM,
|
||||
CLASSIC,
|
||||
NETWORK,
|
||||
TRANSLUCENT
|
||||
TRANSLUCENT, // Deprecated
|
||||
PAINT
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.scene.text.Font;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.java.JavaRuntime;
|
||||
import org.jackhuang.hmcl.util.Lazy;
|
||||
import org.jackhuang.hmcl.util.io.JarUtils;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
@@ -29,9 +28,6 @@ import org.jackhuang.hmcl.util.platform.SystemUtils;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.lang.invoke.MethodHandle;
|
||||
import java.lang.invoke.MethodHandles;
|
||||
import java.lang.invoke.MethodType;
|
||||
import java.net.MalformedURLException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -152,46 +148,33 @@ public final class FontManager {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (JavaRuntime.CURRENT_VERSION >= 9) {
|
||||
try {
|
||||
MethodHandle methodHandle = MethodHandles.publicLookup().findStatic(Font.class, "loadFonts",
|
||||
MethodType.methodType(Font[].class, String.class, double.class));
|
||||
Font[] fonts = Font.loadFonts(file.toUri().toURL().toExternalForm(), DEFAULT_FONT_SIZE);
|
||||
if (fonts == null) {
|
||||
LOG.warning("Failed to load font from " + path);
|
||||
return null;
|
||||
} else if (fonts.length == 0) {
|
||||
LOG.warning("No fonts loaded from " + path);
|
||||
return null;
|
||||
}
|
||||
|
||||
Font[] fonts = (Font[]) methodHandle.invokeExact(file.toUri().toURL().toExternalForm(), DEFAULT_FONT_SIZE);
|
||||
if (fonts == null) {
|
||||
LOG.warning("Failed to load font from " + path);
|
||||
return null;
|
||||
} else if (fonts.length == 0) {
|
||||
LOG.warning("No fonts loaded from " + path);
|
||||
return null;
|
||||
}
|
||||
|
||||
for (Font font : fonts) {
|
||||
if (font.getFamily().equalsIgnoreCase(family)) {
|
||||
return font;
|
||||
}
|
||||
}
|
||||
|
||||
if (family.indexOf(',') >= 0) {
|
||||
for (String candidateFamily : family.split(",")) {
|
||||
for (Font font : fonts) {
|
||||
if (font.getFamily().equalsIgnoreCase(candidateFamily)) {
|
||||
return font;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LOG.warning(String.format("Family '%s' not found in font file '%s'", family, path));
|
||||
return fonts[0];
|
||||
} catch (NoSuchMethodException | IllegalAccessException ignored) {
|
||||
for (Font font : fonts) {
|
||||
if (font.getFamily().equalsIgnoreCase(family)) {
|
||||
return font;
|
||||
}
|
||||
}
|
||||
|
||||
Font font = Font.loadFont(file.toUri().toURL().toExternalForm(), DEFAULT_FONT_SIZE);
|
||||
if (font == null)
|
||||
LOG.warning("Failed to load font from " + path);
|
||||
return font;
|
||||
if (family.indexOf(',') >= 0) {
|
||||
for (String candidateFamily : family.split(",")) {
|
||||
for (Font font : fonts) {
|
||||
if (font.getFamily().equalsIgnoreCase(candidateFamily)) {
|
||||
return font;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LOG.warning(String.format("Family '%s' not found in font file '%s'", family, path));
|
||||
return fonts[0];
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to get default font with fc-match", e);
|
||||
return null;
|
||||
|
||||
@@ -21,10 +21,7 @@ import com.google.gson.*;
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import javafx.beans.InvalidationListener;
|
||||
import javafx.beans.Observable;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.IntegerProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.property.SimpleIntegerProperty;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableSet;
|
||||
import org.jackhuang.hmcl.util.javafx.ObservableHelper;
|
||||
@@ -57,6 +54,8 @@ public final class GlobalConfig implements Observable {
|
||||
|
||||
private final BooleanProperty enableOfflineAccount = new SimpleBooleanProperty(false);
|
||||
|
||||
private final StringProperty fontAntiAliasing = new SimpleStringProperty();
|
||||
|
||||
private final ObservableSet<String> userJava = FXCollections.observableSet(new LinkedHashSet<>());
|
||||
|
||||
private final ObservableSet<String> disabledJava = FXCollections.observableSet(new LinkedHashSet<>());
|
||||
@@ -131,6 +130,18 @@ public final class GlobalConfig implements Observable {
|
||||
enableOfflineAccount.set(value);
|
||||
}
|
||||
|
||||
public StringProperty fontAntiAliasingProperty() {
|
||||
return fontAntiAliasing;
|
||||
}
|
||||
|
||||
public String getFontAntiAliasing() {
|
||||
return fontAntiAliasing.get();
|
||||
}
|
||||
|
||||
public void setFontAntiAliasing(String value) {
|
||||
this.fontAntiAliasing.set(value);
|
||||
}
|
||||
|
||||
public ObservableSet<String> getUserJava() {
|
||||
return userJava;
|
||||
}
|
||||
@@ -146,7 +157,8 @@ public final class GlobalConfig implements Observable {
|
||||
"logRetention",
|
||||
"userJava",
|
||||
"disabledJava",
|
||||
"enableOfflineAccount"
|
||||
"enableOfflineAccount",
|
||||
"fontAntiAliasing"
|
||||
));
|
||||
|
||||
@Override
|
||||
@@ -159,6 +171,7 @@ public final class GlobalConfig implements Observable {
|
||||
jsonObject.add("agreementVersion", context.serialize(src.getAgreementVersion()));
|
||||
jsonObject.add("platformPromptVersion", context.serialize(src.getPlatformPromptVersion()));
|
||||
jsonObject.add("logRetention", context.serialize(src.getLogRetention()));
|
||||
jsonObject.add("fontAntiAliasing", context.serialize(src.getFontAntiAliasing()));
|
||||
if (src.enableOfflineAccount.get())
|
||||
jsonObject.addProperty("enableOfflineAccount", true);
|
||||
|
||||
@@ -186,6 +199,7 @@ public final class GlobalConfig implements Observable {
|
||||
config.setPlatformPromptVersion(Optional.ofNullable(obj.get("platformPromptVersion")).map(JsonElement::getAsInt).orElse(0));
|
||||
config.setLogRetention(Optional.ofNullable(obj.get("logRetention")).map(JsonElement::getAsInt).orElse(20));
|
||||
config.setEnableOfflineAccount(Optional.ofNullable(obj.get("enableOfflineAccount")).map(JsonElement::getAsBoolean).orElse(false));
|
||||
config.setFontAntiAliasing(Optional.ofNullable(obj.get("fontAntiAliasing")).map(JsonElement::getAsString).orElse(null));
|
||||
|
||||
JsonElement userJava = obj.get("userJava");
|
||||
if (userJava != null && userJava.isJsonArray()) {
|
||||
|
||||
@@ -18,13 +18,15 @@
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import javafx.beans.InvalidationListener;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.*;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
@@ -32,7 +34,10 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
public final class ProxyManager {
|
||||
|
||||
private static final ProxySelector NO_PROXY = new SimpleProxySelector(Proxy.NO_PROXY);
|
||||
private static final ProxySelector SYSTEM_DEFAULT = Lang.requireNonNullElse(ProxySelector.getDefault(), NO_PROXY);
|
||||
private static final ProxySelector SYSTEM_DEFAULT = Objects.requireNonNullElse(ProxySelector.getDefault(), NO_PROXY);
|
||||
|
||||
private static volatile @NotNull ProxySelector defaultProxySelector = SYSTEM_DEFAULT;
|
||||
private static volatile @Nullable SimpleAuthenticator defaultAuthenticator = null;
|
||||
|
||||
private static ProxySelector getProxySelector() {
|
||||
if (config().hasProxy()) {
|
||||
@@ -53,7 +58,7 @@ public final class ProxyManager {
|
||||
}
|
||||
}
|
||||
|
||||
private static Authenticator getAuthenticator() {
|
||||
private static SimpleAuthenticator getAuthenticator() {
|
||||
if (config().hasProxy() && config().hasProxyAuth()) {
|
||||
String username = config().getProxyUser();
|
||||
String password = config().getProxyPass();
|
||||
@@ -67,15 +72,34 @@ public final class ProxyManager {
|
||||
}
|
||||
|
||||
static void init() {
|
||||
ProxySelector.setDefault(getProxySelector());
|
||||
InvalidationListener updateProxySelector = observable -> ProxySelector.setDefault(getProxySelector());
|
||||
ProxySelector.setDefault(new ProxySelector() {
|
||||
@Override
|
||||
public List<Proxy> select(URI uri) {
|
||||
return defaultProxySelector.select(uri);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void connectFailed(URI uri, SocketAddress sa, IOException ioe) {
|
||||
defaultProxySelector.connectFailed(uri, sa, ioe);
|
||||
}
|
||||
});
|
||||
Authenticator.setDefault(new Authenticator() {
|
||||
@Override
|
||||
protected PasswordAuthentication getPasswordAuthentication() {
|
||||
var defaultAuthenticator = ProxyManager.defaultAuthenticator;
|
||||
return defaultAuthenticator != null ? defaultAuthenticator.getPasswordAuthentication() : null;
|
||||
}
|
||||
});
|
||||
|
||||
defaultProxySelector = getProxySelector();
|
||||
InvalidationListener updateProxySelector = observable -> defaultProxySelector = getProxySelector();
|
||||
config().proxyTypeProperty().addListener(updateProxySelector);
|
||||
config().proxyHostProperty().addListener(updateProxySelector);
|
||||
config().proxyPortProperty().addListener(updateProxySelector);
|
||||
config().hasProxyProperty().addListener(updateProxySelector);
|
||||
|
||||
Authenticator.setDefault(getAuthenticator());
|
||||
InvalidationListener updateAuthenticator = observable -> Authenticator.setDefault(getAuthenticator());
|
||||
defaultAuthenticator = getAuthenticator();
|
||||
InvalidationListener updateAuthenticator = observable -> defaultAuthenticator = getAuthenticator();
|
||||
config().hasProxyProperty().addListener(updateAuthenticator);
|
||||
config().hasProxyAuthProperty().addListener(updateAuthenticator);
|
||||
config().proxyUserProperty().addListener(updateAuthenticator);
|
||||
@@ -124,7 +148,7 @@ public final class ProxyManager {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected PasswordAuthentication getPasswordAuthentication() {
|
||||
public PasswordAuthentication getPasswordAuthentication() {
|
||||
return getRequestorType() == RequestorType.PROXY ? new PasswordAuthentication(username, password) : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ import javafx.collections.ObservableList;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.paint.Color;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.Charset;
|
||||
@@ -68,7 +67,7 @@ public final class StyleSheets {
|
||||
Path temp = Files.createTempFile("hmcl", ".css");
|
||||
// For JavaFX 17 or earlier, CssParser uses the default charset
|
||||
// https://bugs.openjdk.org/browse/JDK-8279328
|
||||
FileUtils.writeText(temp, styleSheet, Charset.defaultCharset());
|
||||
Files.writeString(temp, styleSheet, Charset.defaultCharset());
|
||||
temp.toFile().deleteOnExit();
|
||||
return temp.toUri().toString();
|
||||
} catch (IOException | NullPointerException e) {
|
||||
|
||||
@@ -33,7 +33,8 @@ public enum VersionIconType {
|
||||
FORGE("/assets/img/forge.png"),
|
||||
NEO_FORGE("/assets/img/neoforge.png"),
|
||||
FURNACE("/assets/img/furnace.png"),
|
||||
QUILT("/assets/img/quilt.png");
|
||||
QUILT("/assets/img/quilt.png"),
|
||||
APRIL_FOOLS("/assets/img/april_fools.png");
|
||||
|
||||
// Please append new items at last
|
||||
|
||||
|
||||
@@ -797,14 +797,6 @@ public final class VersionSetting implements Cloneable, Observable {
|
||||
return obj;
|
||||
}
|
||||
|
||||
private static <T> T getOrDefault(T[] values, JsonElement index, T defaultValue) {
|
||||
if (index == null)
|
||||
return defaultValue;
|
||||
|
||||
int idx = index.getAsInt();
|
||||
return idx >= 0 && idx < values.length ? values[idx] : defaultValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public VersionSetting deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
|
||||
if (!(json instanceof JsonObject))
|
||||
@@ -839,14 +831,14 @@ public final class VersionSetting implements Cloneable, Observable {
|
||||
vs.setNotCheckJVM(Optional.ofNullable(obj.get("notCheckJVM")).map(JsonElement::getAsBoolean).orElse(false));
|
||||
vs.setNotPatchNatives(Optional.ofNullable(obj.get("notPatchNatives")).map(JsonElement::getAsBoolean).orElse(false));
|
||||
vs.setShowLogs(Optional.ofNullable(obj.get("showLogs")).map(JsonElement::getAsBoolean).orElse(false));
|
||||
vs.setLauncherVisibility(getOrDefault(LauncherVisibility.values(), obj.get("launcherVisibility"), LauncherVisibility.HIDE));
|
||||
vs.setProcessPriority(getOrDefault(ProcessPriority.values(), obj.get("processPriority"), ProcessPriority.NORMAL));
|
||||
vs.setLauncherVisibility(parseJsonPrimitive(obj.getAsJsonPrimitive("launcherVisibility"), LauncherVisibility.class, LauncherVisibility.HIDE));
|
||||
vs.setProcessPriority(parseJsonPrimitive(obj.getAsJsonPrimitive("processPriority"), ProcessPriority.class, ProcessPriority.NORMAL));
|
||||
vs.setUseNativeGLFW(Optional.ofNullable(obj.get("useNativeGLFW")).map(JsonElement::getAsBoolean).orElse(false));
|
||||
vs.setUseNativeOpenAL(Optional.ofNullable(obj.get("useNativeOpenAL")).map(JsonElement::getAsBoolean).orElse(false));
|
||||
vs.setGameDirType(getOrDefault(GameDirectoryType.values(), obj.get("gameDirType"), GameDirectoryType.ROOT_FOLDER));
|
||||
vs.setGameDirType(parseJsonPrimitive(obj.getAsJsonPrimitive("gameDirType"), GameDirectoryType.class, GameDirectoryType.ROOT_FOLDER));
|
||||
vs.setDefaultJavaPath(Optional.ofNullable(obj.get("defaultJavaPath")).map(JsonElement::getAsString).orElse(null));
|
||||
vs.setNativesDirType(getOrDefault(NativesDirectoryType.values(), obj.get("nativesDirType"), NativesDirectoryType.VERSION_FOLDER));
|
||||
vs.setVersionIcon(getOrDefault(VersionIconType.values(), obj.get("versionIcon"), VersionIconType.DEFAULT));
|
||||
vs.setNativesDirType(parseJsonPrimitive(obj.getAsJsonPrimitive("nativesDirType"), NativesDirectoryType.class, NativesDirectoryType.VERSION_FOLDER));
|
||||
vs.setVersionIcon(parseJsonPrimitive(obj.getAsJsonPrimitive("versionIcon"), VersionIconType.class, VersionIconType.DEFAULT));
|
||||
|
||||
if (obj.get("javaVersionType") != null) {
|
||||
JavaVersionType javaVersionType = parseJsonPrimitive(obj.getAsJsonPrimitive("javaVersionType"), JavaVersionType.class, JavaVersionType.AUTO);
|
||||
|
||||
@@ -42,7 +42,6 @@ import org.jackhuang.hmcl.Launcher;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.game.ModpackHelper;
|
||||
import org.jackhuang.hmcl.java.JavaManager;
|
||||
import org.jackhuang.hmcl.java.JavaRuntime;
|
||||
import org.jackhuang.hmcl.setting.*;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.task.TaskExecutor;
|
||||
@@ -176,6 +175,18 @@ public final class Controllers {
|
||||
public static void initialize(Stage stage) {
|
||||
LOG.info("Start initializing application");
|
||||
|
||||
if (System.getProperty("prism.lcdtext") == null) {
|
||||
String fontAntiAliasing = globalConfig().getFontAntiAliasing();
|
||||
if ("lcd".equalsIgnoreCase(fontAntiAliasing)) {
|
||||
LOG.info("Enable sub-pixel antialiasing");
|
||||
System.getProperties().put("prism.lcdtext", "true");
|
||||
} else if ("gray".equalsIgnoreCase(fontAntiAliasing)
|
||||
|| OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && SCREEN.getOutputScaleX() > 1) {
|
||||
LOG.info("Disable sub-pixel antialiasing");
|
||||
System.getProperties().put("prism.lcdtext", "false");
|
||||
}
|
||||
}
|
||||
|
||||
Controllers.stage = stage;
|
||||
|
||||
stageSizeChangeListener = o -> {
|
||||
@@ -205,7 +216,11 @@ public final class Controllers {
|
||||
|
||||
if (targetProperty != null
|
||||
&& Controllers.stage != null
|
||||
&& !Controllers.stage.isIconified()) {
|
||||
&& !Controllers.stage.isIconified()
|
||||
// https://github.com/HMCL-dev/HMCL/issues/4290
|
||||
&& (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS ||
|
||||
!Controllers.stage.isFullScreen() && !Controllers.stage.isMaximized())
|
||||
) {
|
||||
targetProperty.set(sourceProperty.get());
|
||||
}
|
||||
};
|
||||
@@ -310,26 +325,6 @@ public final class Controllers {
|
||||
}
|
||||
}
|
||||
|
||||
if (JavaRuntime.CURRENT_VERSION < 10) {
|
||||
Number shownTipVersion = null;
|
||||
|
||||
try {
|
||||
shownTipVersion = (Number) config().getShownTips().get(JAVA_VERSION_TIP);
|
||||
} catch (ClassCastException e) {
|
||||
LOG.warning("Invalid type for shown tips key: " + JAVA_VERSION_TIP, e);
|
||||
}
|
||||
|
||||
if (shownTipVersion == null || shownTipVersion.intValue() < Metadata.MINIMUM_SUPPORTED_JAVA_VERSION) {
|
||||
MessageDialogPane.Builder builder = new MessageDialogPane.Builder(i18n("fatal.deprecated_java_version"), null, MessageType.WARNING);
|
||||
String downloadLink = Metadata.getSuggestedJavaDownloadLink();
|
||||
if (downloadLink != null)
|
||||
builder.addHyperLink(i18n("fatal.deprecated_java_version.download_link", 21), downloadLink);
|
||||
Controllers.dialog(builder
|
||||
.ok(() -> config().getShownTips().put(JAVA_VERSION_TIP, Metadata.MINIMUM_SUPPORTED_JAVA_VERSION))
|
||||
.build());
|
||||
}
|
||||
}
|
||||
|
||||
if (globalConfig().getAgreementVersion() < 1) {
|
||||
JFXDialogLayout agreementPane = new JFXDialogLayout();
|
||||
agreementPane.setHeading(new Label(i18n("launcher.agreement")));
|
||||
|
||||
@@ -18,16 +18,16 @@
|
||||
package org.jackhuang.hmcl.ui;
|
||||
|
||||
import com.jfoenix.controls.*;
|
||||
import com.twelvemonkeys.imageio.plugins.webp.WebPImageReaderSpi;
|
||||
import javafx.animation.*;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.InvalidationListener;
|
||||
import javafx.beans.Observable;
|
||||
import javafx.beans.WeakInvalidationListener;
|
||||
import javafx.beans.WeakListener;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.beans.value.*;
|
||||
import javafx.collections.ObservableMap;
|
||||
import javafx.event.Event;
|
||||
import javafx.event.EventDispatcher;
|
||||
import javafx.event.EventType;
|
||||
@@ -41,11 +41,9 @@ import javafx.scene.control.*;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.input.*;
|
||||
import javafx.scene.layout.ColumnConstraints;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.paint.Paint;
|
||||
import javafx.scene.shape.Rectangle;
|
||||
import javafx.scene.text.Text;
|
||||
import javafx.scene.text.TextFlow;
|
||||
@@ -54,11 +52,12 @@ import javafx.stage.Stage;
|
||||
import javafx.util.Callback;
|
||||
import javafx.util.Duration;
|
||||
import javafx.util.StringConverter;
|
||||
import org.glavo.png.PNGType;
|
||||
import org.glavo.png.PNGWriter;
|
||||
import org.glavo.png.javafx.PNGJavaFXUtils;
|
||||
import org.jackhuang.hmcl.task.CacheFileTask;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
|
||||
import org.jackhuang.hmcl.ui.image.ImageLoader;
|
||||
import org.jackhuang.hmcl.ui.image.ImageUtils;
|
||||
import org.jackhuang.hmcl.util.*;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
@@ -66,19 +65,19 @@ import org.jackhuang.hmcl.util.javafx.ExtendedProperties;
|
||||
import org.jackhuang.hmcl.util.javafx.SafeStringConverter;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jackhuang.hmcl.util.platform.SystemUtils;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.Element;
|
||||
import org.w3c.dom.NodeList;
|
||||
import org.xml.sax.InputSource;
|
||||
import org.xml.sax.SAXException;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.imageio.ImageReader;
|
||||
import javax.imageio.stream.ImageInputStream;
|
||||
import javax.xml.parsers.DocumentBuilder;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import javax.xml.parsers.ParserConfigurationException;
|
||||
import java.io.*;
|
||||
import java.lang.invoke.MethodHandles;
|
||||
import java.lang.invoke.MethodType;
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.net.*;
|
||||
import java.nio.file.Files;
|
||||
@@ -105,6 +104,11 @@ public final class FXUtils {
|
||||
|
||||
public static final int JAVAFX_MAJOR_VERSION;
|
||||
|
||||
/// @see Platform.Preferences
|
||||
public static final @Nullable ObservableMap<String, Object> PREFERENCES;
|
||||
public static final @Nullable ObservableBooleanValue DARK_MODE;
|
||||
public static final @Nullable Boolean REDUCED_MOTION;
|
||||
|
||||
static {
|
||||
String jfxVersion = System.getProperty("javafx.version");
|
||||
int majorVersion = -1;
|
||||
@@ -115,27 +119,51 @@ public final class FXUtils {
|
||||
}
|
||||
}
|
||||
JAVAFX_MAJOR_VERSION = majorVersion;
|
||||
|
||||
ObservableMap<String, Object> preferences = null;
|
||||
ObservableBooleanValue darkMode = null;
|
||||
Boolean reducedMotion = null;
|
||||
if (JAVAFX_MAJOR_VERSION >= 22) {
|
||||
try {
|
||||
MethodHandles.Lookup lookup = MethodHandles.publicLookup();
|
||||
Class<?> preferencesClass = Class.forName("javafx.application.Platform$Preferences");
|
||||
@SuppressWarnings("unchecked")
|
||||
var preferences0 = (ObservableMap<String, Object>) lookup.findStatic(Platform.class, "getPreferences", MethodType.methodType(preferencesClass))
|
||||
.invoke();
|
||||
preferences = preferences0;
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
var colorSchemeProperty =
|
||||
(ReadOnlyObjectProperty<? extends Enum<?>>)
|
||||
lookup.findVirtual(preferencesClass, "colorSchemeProperty", MethodType.methodType(ReadOnlyObjectProperty.class))
|
||||
.invoke(preferences);
|
||||
|
||||
darkMode = Bindings.createBooleanBinding(() ->
|
||||
"DARK".equals(colorSchemeProperty.get().name()), colorSchemeProperty);
|
||||
|
||||
if (JAVAFX_MAJOR_VERSION >= 24) {
|
||||
reducedMotion = (boolean)
|
||||
lookup.findVirtual(preferencesClass, "isReducedMotion", MethodType.methodType(boolean.class))
|
||||
.invoke(preferences);
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to get preferences", e);
|
||||
}
|
||||
}
|
||||
PREFERENCES = preferences;
|
||||
DARK_MODE = darkMode;
|
||||
REDUCED_MOTION = reducedMotion;
|
||||
}
|
||||
|
||||
public static final String DEFAULT_MONOSPACE_FONT = OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS ? "Consolas" : "Monospace";
|
||||
|
||||
public static final List<String> IMAGE_EXTENSIONS = Lang.immutableListOf(
|
||||
"png", "jpg", "jpeg", "bmp", "gif", "webp"
|
||||
"png", "jpg", "jpeg", "bmp", "gif", "webp", "apng"
|
||||
);
|
||||
|
||||
private static final Map<String, Image> builtinImageCache = new ConcurrentHashMap<>();
|
||||
private static final Map<String, Path> remoteImageCache = new ConcurrentHashMap<>();
|
||||
|
||||
public static void shutdown() {
|
||||
for (Map.Entry<String, Path> entry : remoteImageCache.entrySet()) {
|
||||
try {
|
||||
Files.deleteIfExists(entry.getValue());
|
||||
} catch (IOException e) {
|
||||
LOG.warning(String.format("Failed to delete cache file %s.", entry.getValue()), e);
|
||||
}
|
||||
remoteImageCache.remove(entry.getKey());
|
||||
}
|
||||
|
||||
builtinImageCache.clear();
|
||||
}
|
||||
|
||||
@@ -328,8 +356,15 @@ public final class FXUtils {
|
||||
private static final Duration TOOLTIP_SLOW_SHOW_DELAY = Duration.millis(500);
|
||||
private static final Duration TOOLTIP_SHOW_DURATION = Duration.millis(5000);
|
||||
|
||||
public static void installTooltip(Node node, Duration showDelay, Duration showDuration, Duration hideDelay, Tooltip tooltip) {
|
||||
tooltip.setShowDelay(showDelay);
|
||||
tooltip.setShowDuration(showDuration);
|
||||
tooltip.setHideDelay(hideDelay);
|
||||
Tooltip.install(node, tooltip);
|
||||
}
|
||||
|
||||
public static void installFastTooltip(Node node, Tooltip tooltip) {
|
||||
runInFX(() -> TooltipInstaller.INSTALLER.installTooltip(node, TOOLTIP_FAST_SHOW_DELAY, TOOLTIP_SHOW_DURATION, Duration.ZERO, tooltip));
|
||||
runInFX(() -> installTooltip(node, TOOLTIP_FAST_SHOW_DELAY, TOOLTIP_SHOW_DURATION, Duration.ZERO, tooltip));
|
||||
}
|
||||
|
||||
public static void installFastTooltip(Node node, String tooltip) {
|
||||
@@ -337,7 +372,7 @@ public final class FXUtils {
|
||||
}
|
||||
|
||||
public static void installSlowTooltip(Node node, Tooltip tooltip) {
|
||||
runInFX(() -> TooltipInstaller.INSTALLER.installTooltip(node, TOOLTIP_SLOW_SHOW_DELAY, TOOLTIP_SHOW_DURATION, Duration.ZERO, tooltip));
|
||||
runInFX(() -> installTooltip(node, TOOLTIP_SLOW_SHOW_DELAY, TOOLTIP_SHOW_DURATION, Duration.ZERO, tooltip));
|
||||
}
|
||||
|
||||
public static void installSlowTooltip(Node node, String tooltip) {
|
||||
@@ -700,6 +735,240 @@ public final class FXUtils {
|
||||
property.removeListener(binding);
|
||||
}
|
||||
|
||||
private static final class PaintBidirectionalBinding implements InvalidationListener, WeakListener {
|
||||
private final WeakReference<ColorPicker> colorPickerRef;
|
||||
private final WeakReference<Property<Paint>> propertyRef;
|
||||
private final int hashCode;
|
||||
|
||||
private boolean updating = false;
|
||||
|
||||
private PaintBidirectionalBinding(ColorPicker colorPicker, Property<Paint> property) {
|
||||
this.colorPickerRef = new WeakReference<>(colorPicker);
|
||||
this.propertyRef = new WeakReference<>(property);
|
||||
this.hashCode = System.identityHashCode(colorPicker) ^ System.identityHashCode(property);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void invalidated(Observable sourceProperty) {
|
||||
if (!updating) {
|
||||
final ColorPicker colorPicker = colorPickerRef.get();
|
||||
final Property<Paint> property = propertyRef.get();
|
||||
|
||||
if (colorPicker == null || property == null) {
|
||||
if (colorPicker != null) {
|
||||
colorPicker.valueProperty().removeListener(this);
|
||||
}
|
||||
|
||||
if (property != null) {
|
||||
property.removeListener(this);
|
||||
}
|
||||
} else {
|
||||
updating = true;
|
||||
try {
|
||||
if (property == sourceProperty) {
|
||||
Paint newValue = property.getValue();
|
||||
if (newValue instanceof Color)
|
||||
colorPicker.setValue((Color) newValue);
|
||||
else
|
||||
colorPicker.setValue(null);
|
||||
} else {
|
||||
Paint newValue = colorPicker.getValue();
|
||||
property.setValue(newValue);
|
||||
}
|
||||
} finally {
|
||||
updating = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean wasGarbageCollected() {
|
||||
return colorPickerRef.get() == null || propertyRef.get() == null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return hashCode;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o)
|
||||
return true;
|
||||
if (!(o instanceof FXUtils.PaintBidirectionalBinding))
|
||||
return false;
|
||||
|
||||
var that = (FXUtils.PaintBidirectionalBinding) o;
|
||||
|
||||
final ColorPicker colorPicker = this.colorPickerRef.get();
|
||||
final Property<Paint> property = this.propertyRef.get();
|
||||
|
||||
final ColorPicker thatColorPicker = that.colorPickerRef.get();
|
||||
final Property<?> thatProperty = that.propertyRef.get();
|
||||
|
||||
if (colorPicker == null || property == null || thatColorPicker == null || thatProperty == null)
|
||||
return false;
|
||||
|
||||
return colorPicker == thatColorPicker && property == thatProperty;
|
||||
}
|
||||
}
|
||||
|
||||
public static void bindPaint(ColorPicker colorPicker, Property<Paint> property) {
|
||||
PaintBidirectionalBinding binding = new PaintBidirectionalBinding(colorPicker, property);
|
||||
|
||||
colorPicker.valueProperty().removeListener(binding);
|
||||
property.removeListener(binding);
|
||||
|
||||
if (property.getValue() instanceof Color)
|
||||
colorPicker.setValue((Color) property.getValue());
|
||||
else
|
||||
colorPicker.setValue(null);
|
||||
|
||||
colorPicker.valueProperty().addListener(binding);
|
||||
property.addListener(binding);
|
||||
}
|
||||
|
||||
private static final class WindowsSizeBidirectionalBinding implements InvalidationListener, WeakListener {
|
||||
private final WeakReference<JFXComboBox<String>> comboBoxRef;
|
||||
private final WeakReference<IntegerProperty> widthPropertyRef;
|
||||
private final WeakReference<IntegerProperty> heightPropertyRef;
|
||||
|
||||
private final int hashCode;
|
||||
|
||||
private boolean updating = false;
|
||||
|
||||
private WindowsSizeBidirectionalBinding(JFXComboBox<String> comboBox,
|
||||
IntegerProperty widthProperty,
|
||||
IntegerProperty heightProperty) {
|
||||
this.comboBoxRef = new WeakReference<>(comboBox);
|
||||
this.widthPropertyRef = new WeakReference<>(widthProperty);
|
||||
this.heightPropertyRef = new WeakReference<>(heightProperty);
|
||||
this.hashCode = System.identityHashCode(comboBox)
|
||||
^ System.identityHashCode(widthProperty)
|
||||
^ System.identityHashCode(heightProperty);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void invalidated(Observable observable) {
|
||||
if (!updating) {
|
||||
var comboBox = this.comboBoxRef.get();
|
||||
var widthProperty = this.widthPropertyRef.get();
|
||||
var heightProperty = this.heightPropertyRef.get();
|
||||
|
||||
if (comboBox == null || widthProperty == null || heightProperty == null) {
|
||||
if (comboBox != null) {
|
||||
comboBox.focusedProperty().removeListener(this);
|
||||
comboBox.sceneProperty().removeListener(this);
|
||||
}
|
||||
if (widthProperty != null)
|
||||
widthProperty.removeListener(this);
|
||||
if (heightProperty != null)
|
||||
heightProperty.removeListener(this);
|
||||
} else {
|
||||
updating = true;
|
||||
try {
|
||||
int width = widthProperty.get();
|
||||
int height = heightProperty.get();
|
||||
|
||||
if (observable instanceof ReadOnlyProperty<?>
|
||||
&& ((ReadOnlyProperty<?>) observable).getBean() == comboBox) {
|
||||
String value = comboBox.valueProperty().get();
|
||||
if (value == null)
|
||||
value = "";
|
||||
int idx = value.indexOf('x');
|
||||
if (idx < 0)
|
||||
idx = value.indexOf('*');
|
||||
|
||||
if (idx < 0) {
|
||||
LOG.warning("Bad window size: " + value);
|
||||
comboBox.setValue(width + "x" + height);
|
||||
return;
|
||||
}
|
||||
|
||||
String widthStr = value.substring(0, idx).trim();
|
||||
String heightStr = value.substring(idx + 1).trim();
|
||||
|
||||
int newWidth;
|
||||
int newHeight;
|
||||
try {
|
||||
newWidth = Integer.parseInt(widthStr);
|
||||
newHeight = Integer.parseInt(heightStr);
|
||||
} catch (NumberFormatException e) {
|
||||
LOG.warning("Bad window size: " + value);
|
||||
comboBox.setValue(width + "x" + height);
|
||||
return;
|
||||
}
|
||||
|
||||
widthProperty.set(newWidth);
|
||||
heightProperty.set(newHeight);
|
||||
} else {
|
||||
comboBox.setValue(width + "x" + height);
|
||||
}
|
||||
} finally {
|
||||
updating = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean wasGarbageCollected() {
|
||||
return this.comboBoxRef.get() == null
|
||||
|| this.widthPropertyRef.get() == null
|
||||
|| this.heightPropertyRef.get() == null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return hashCode;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object obj) {
|
||||
if (this == obj)
|
||||
return true;
|
||||
if (!(obj instanceof WindowsSizeBidirectionalBinding))
|
||||
return false;
|
||||
|
||||
var that = (WindowsSizeBidirectionalBinding) obj;
|
||||
|
||||
var comboBox = this.comboBoxRef.get();
|
||||
var widthProperty = this.widthPropertyRef.get();
|
||||
var heightProperty = this.heightPropertyRef.get();
|
||||
|
||||
var thatComboBox = that.comboBoxRef.get();
|
||||
var thatWidthProperty = that.widthPropertyRef.get();
|
||||
var thatHeightProperty = that.heightPropertyRef.get();
|
||||
|
||||
if (comboBox == null || widthProperty == null || heightProperty == null
|
||||
|| thatComboBox == null || thatWidthProperty == null || thatHeightProperty == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return comboBox == thatComboBox
|
||||
&& widthProperty == thatWidthProperty
|
||||
&& heightProperty == thatHeightProperty;
|
||||
}
|
||||
}
|
||||
|
||||
public static void bindWindowsSize(JFXComboBox<String> comboBox, IntegerProperty widthProperty, IntegerProperty heightProperty) {
|
||||
comboBox.setValue(widthProperty.get() + "x" + heightProperty.get());
|
||||
var binding = new WindowsSizeBidirectionalBinding(comboBox, widthProperty, heightProperty);
|
||||
comboBox.focusedProperty().addListener(binding);
|
||||
comboBox.sceneProperty().addListener(binding);
|
||||
widthProperty.addListener(binding);
|
||||
heightProperty.addListener(binding);
|
||||
}
|
||||
|
||||
public static void unbindWindowsSize(JFXComboBox<String> comboBox, IntegerProperty widthProperty, IntegerProperty heightProperty) {
|
||||
var binding = new WindowsSizeBidirectionalBinding(comboBox, widthProperty, heightProperty);
|
||||
comboBox.focusedProperty().removeListener(binding);
|
||||
comboBox.sceneProperty().removeListener(binding);
|
||||
widthProperty.removeListener(binding);
|
||||
heightProperty.removeListener(binding);
|
||||
}
|
||||
|
||||
public static void bindAllEnabled(BooleanProperty allEnabled, BooleanProperty... children) {
|
||||
int itemCount = children.length;
|
||||
int childSelectedCount = 0;
|
||||
@@ -767,47 +1036,53 @@ public final class FXUtils {
|
||||
stage.getIcons().add(newBuiltinImage(icon));
|
||||
}
|
||||
|
||||
private static Image loadWebPImage(InputStream input) throws IOException {
|
||||
WebPImageReaderSpi spi = new WebPImageReaderSpi();
|
||||
ImageReader reader = spi.createReaderInstance(null);
|
||||
|
||||
try (ImageInputStream imageInput = ImageIO.createImageInputStream(input)) {
|
||||
reader.setInput(imageInput, true, true);
|
||||
return SwingFXUtils.toFXImage(reader.read(0, reader.getDefaultReadParam()), null);
|
||||
} finally {
|
||||
reader.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public static Image loadImage(Path path) throws Exception {
|
||||
try (InputStream input = Files.newInputStream(path)) {
|
||||
if ("webp".equalsIgnoreCase(FileUtils.getExtension(path)))
|
||||
return loadWebPImage(input);
|
||||
else {
|
||||
Image image = new Image(input);
|
||||
if (image.isError())
|
||||
throw image.getException();
|
||||
return image;
|
||||
return loadImage(path, 0, 0, false, false);
|
||||
}
|
||||
|
||||
public static Image loadImage(Path path,
|
||||
int requestedWidth, int requestedHeight,
|
||||
boolean preserveRatio, boolean smooth) throws Exception {
|
||||
try (var input = new BufferedInputStream(Files.newInputStream(path))) {
|
||||
String ext = FileUtils.getExtension(path).toLowerCase(Locale.ROOT);
|
||||
ImageLoader loader = ImageUtils.EXT_TO_LOADER.get(ext);
|
||||
if (loader == null && !ImageUtils.DEFAULT_EXTS.contains(ext)) {
|
||||
input.mark(ImageUtils.HEADER_BUFFER_SIZE);
|
||||
byte[] headerBuffer = input.readNBytes(ImageUtils.HEADER_BUFFER_SIZE);
|
||||
input.reset();
|
||||
loader = ImageUtils.guessLoader(headerBuffer);
|
||||
}
|
||||
if (loader == null)
|
||||
loader = ImageUtils.DEFAULT;
|
||||
return loader.load(input, requestedWidth, requestedHeight, preserveRatio, smooth);
|
||||
}
|
||||
}
|
||||
|
||||
public static Image loadImage(URL url) throws Exception {
|
||||
URLConnection connection = NetworkUtils.createConnection(url);
|
||||
if (connection instanceof HttpURLConnection) {
|
||||
connection = NetworkUtils.resolveConnection((HttpURLConnection) connection);
|
||||
}
|
||||
public static Image loadImage(String url) throws Exception {
|
||||
URI uri = NetworkUtils.toURI(url);
|
||||
|
||||
try (InputStream input = connection.getInputStream()) {
|
||||
String path = url.getPath();
|
||||
if (path != null && "webp".equalsIgnoreCase(StringUtils.substringAfterLast(path, '.')))
|
||||
return loadWebPImage(input);
|
||||
else {
|
||||
Image image = new Image(input);
|
||||
if (image.isError())
|
||||
throw image.getException();
|
||||
return image;
|
||||
URLConnection connection = NetworkUtils.createConnection(uri);
|
||||
if (connection instanceof HttpURLConnection)
|
||||
connection = NetworkUtils.resolveConnection((HttpURLConnection) connection);
|
||||
|
||||
try (BufferedInputStream input = new BufferedInputStream(connection.getInputStream())) {
|
||||
String contentType = Objects.requireNonNull(connection.getContentType(), "");
|
||||
Matcher matcher = ImageUtils.CONTENT_TYPE_PATTERN.matcher(contentType);
|
||||
if (matcher.find())
|
||||
contentType = matcher.group("type");
|
||||
|
||||
ImageLoader loader = ImageUtils.CONTENT_TYPE_TO_LOADER.get(contentType);
|
||||
if (loader == null && !ImageUtils.DEFAULT_CONTENT_TYPES.contains(contentType)) {
|
||||
input.mark(ImageUtils.HEADER_BUFFER_SIZE);
|
||||
byte[] headerBuffer = input.readNBytes(ImageUtils.HEADER_BUFFER_SIZE);
|
||||
input.reset();
|
||||
loader = ImageUtils.guessLoader(headerBuffer);
|
||||
}
|
||||
|
||||
if (loader == null)
|
||||
loader = ImageUtils.DEFAULT;
|
||||
|
||||
return loader.load(input, 0, 0, false, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -851,78 +1126,22 @@ public final class FXUtils {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load image from the internet. It will cache the data of images for the further usage.
|
||||
* The cached data will be deleted when HMCL is closed or hidden.
|
||||
*
|
||||
* @param url the url of image. The image resource should be a file on the internet.
|
||||
* @return the image resource within the jar.
|
||||
*/
|
||||
public static Image newRemoteImage(String url) {
|
||||
return newRemoteImage(url, 0, 0, false, false, false);
|
||||
public static Task<Image> getRemoteImageTask(String url, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) {
|
||||
return new CacheFileTask(url)
|
||||
.thenApplyAsync(file -> loadImage(file, requestedWidth, requestedHeight, preserveRatio, smooth));
|
||||
}
|
||||
|
||||
/**
|
||||
* Load image from the internet. It will cache the data of images for the further usage.
|
||||
* The cached data will be deleted when HMCL is closed or hidden.
|
||||
*
|
||||
* @param url the url of image. The image resource should be a file on the internet.
|
||||
* @param requestedWidth the image's bounding box width
|
||||
* @param requestedHeight the image's bounding box height
|
||||
* @param preserveRatio indicates whether to preserve the aspect ratio of
|
||||
* the original image when scaling to fit the image within the
|
||||
* specified bounding box
|
||||
* @param smooth indicates whether to use a better quality filtering
|
||||
* algorithm or a faster one when scaling this image to fit within
|
||||
* the specified bounding box
|
||||
* @return the image resource within the jar.
|
||||
*/
|
||||
public static Image newRemoteImage(String url, double requestedWidth, double requestedHeight, boolean preserveRatio, boolean smooth, boolean backgroundLoading) {
|
||||
Path currentPath = remoteImageCache.get(url);
|
||||
if (currentPath != null) {
|
||||
if (Files.isReadable(currentPath)) {
|
||||
try (InputStream inputStream = Files.newInputStream(currentPath)) {
|
||||
return new Image(inputStream, requestedWidth, requestedHeight, preserveRatio, smooth);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("An exception encountered while reading data from cached image file.", e);
|
||||
}
|
||||
}
|
||||
|
||||
// The file is unavailable or unreadable.
|
||||
remoteImageCache.remove(url);
|
||||
|
||||
try {
|
||||
Files.deleteIfExists(currentPath);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("An exception encountered while deleting broken cached image file.", e);
|
||||
}
|
||||
}
|
||||
|
||||
Image image = new Image(url, requestedWidth, requestedHeight, preserveRatio, smooth, backgroundLoading);
|
||||
image.progressProperty().addListener((observable, oldValue, newValue) -> {
|
||||
if (newValue.doubleValue() >= 1.0 && !image.isError() && image.getPixelReader() != null && image.getWidth() > 0.0 && image.getHeight() > 0.0) {
|
||||
Task.runAsync(() -> {
|
||||
Path newPath = Files.createTempFile("hmcl-net-resource-cache-", ".cache");
|
||||
try ( // Make sure the file is released from JVM before we put the path into remoteImageCache.
|
||||
OutputStream outputStream = Files.newOutputStream(newPath);
|
||||
PNGWriter writer = new PNGWriter(outputStream, PNGType.RGBA, PNGWriter.DEFAULT_COMPRESS_LEVEL)
|
||||
) {
|
||||
writer.write(PNGJavaFXUtils.asArgbImage(image));
|
||||
} catch (IOException e) {
|
||||
try {
|
||||
Files.delete(newPath);
|
||||
} catch (IOException e2) {
|
||||
e2.addSuppressed(e);
|
||||
throw e2;
|
||||
}
|
||||
throw e;
|
||||
public static ObservableValue<Image> newRemoteImage(String url, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) {
|
||||
var image = new SimpleObjectProperty<Image>();
|
||||
getRemoteImageTask(url, requestedWidth, requestedHeight, preserveRatio, smooth)
|
||||
.whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (exception == null) {
|
||||
image.set(result);
|
||||
} else {
|
||||
LOG.warning("An exception encountered while loading remote image: " + url, exception);
|
||||
}
|
||||
if (remoteImageCache.putIfAbsent(url, newPath) != null) {
|
||||
Files.delete(newPath); // The image has been loaded in another task. Delete the image here in order not to pollute the tmp folder.
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
});
|
||||
})
|
||||
.start();
|
||||
return image;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import javafx.scene.image.Image;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.text.Text;
|
||||
import javafx.scene.text.TextFlow;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jsoup.nodes.Node;
|
||||
import org.jsoup.nodes.TextNode;
|
||||
|
||||
@@ -159,28 +160,19 @@ public final class HTMLRenderer {
|
||||
|
||||
private void appendImage(Node node) {
|
||||
String src = node.absUrl("src");
|
||||
URI imageUri = null;
|
||||
try {
|
||||
if (!src.isEmpty())
|
||||
imageUri = URI.create(src);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
|
||||
String alt = node.attr("alt");
|
||||
|
||||
if (imageUri != null) {
|
||||
URI uri = URI.create(src);
|
||||
|
||||
if (StringUtils.isNotBlank(src)) {
|
||||
String widthAttr = node.attr("width");
|
||||
String heightAttr = node.attr("height");
|
||||
|
||||
double width = 0;
|
||||
double height = 0;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
|
||||
if (!widthAttr.isEmpty() && !heightAttr.isEmpty()) {
|
||||
try {
|
||||
width = Double.parseDouble(widthAttr);
|
||||
height = Double.parseDouble(heightAttr);
|
||||
width = (int) Double.parseDouble(widthAttr);
|
||||
height = (int) Double.parseDouble(heightAttr);
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
|
||||
@@ -190,10 +182,12 @@ public final class HTMLRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
Image image = FXUtils.newRemoteImage(uri.toString(), width, height, true, true, false);
|
||||
if (image.isError()) {
|
||||
LOG.warning("Failed to load image: " + uri, image.getException());
|
||||
} else {
|
||||
try {
|
||||
Image image = FXUtils.getRemoteImageTask(src, width, height, true, true)
|
||||
.run();
|
||||
if (image == null)
|
||||
throw new AssertionError("Image loading task returned null");
|
||||
|
||||
ImageView imageView = new ImageView(image);
|
||||
if (hyperlink != null) {
|
||||
URI target = resolveLink(hyperlink);
|
||||
@@ -204,6 +198,8 @@ public final class HTMLRenderer {
|
||||
}
|
||||
children.add(imageView);
|
||||
return;
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to load image: " + src, e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,6 @@ import javafx.scene.input.MouseButton;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.stage.Stage;
|
||||
import org.jackhuang.hmcl.game.GameDumpGenerator;
|
||||
import org.jackhuang.hmcl.game.LauncherHelper;
|
||||
import org.jackhuang.hmcl.game.Log;
|
||||
import org.jackhuang.hmcl.setting.StyleSheets;
|
||||
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
|
||||
@@ -186,7 +185,7 @@ public final class LogWindow extends Stage {
|
||||
}
|
||||
|
||||
private void onTerminateGame() {
|
||||
LauncherHelper.stopManagedProcesses();
|
||||
LogWindow.this.gameProcess.stop();
|
||||
}
|
||||
|
||||
private void onClear() {
|
||||
@@ -224,7 +223,7 @@ public final class LogWindow extends Stage {
|
||||
|
||||
try {
|
||||
if (gameProcess.isRunning()) {
|
||||
GameDumpGenerator.writeDumpTo(gameProcess.getPID(), dumpFile);
|
||||
GameDumpGenerator.writeDumpTo(gameProcess.getProcess().pid(), dumpFile);
|
||||
FXUtils.showFileInExplorer(dumpFile);
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
@@ -307,8 +306,8 @@ public final class LogWindow extends Stage {
|
||||
listView.scrollTo(listView.getItems().size() - 1);
|
||||
});
|
||||
|
||||
listView.setStyle("-fx-font-family: " + Lang.requireNonNullElse(config().getFontFamily(), FXUtils.DEFAULT_MONOSPACE_FONT)
|
||||
+ "; -fx-font-size: " + config().getFontSize() + "px;");
|
||||
listView.setStyle("-fx-font-family: \"" + Lang.requireNonNullElse(config().getFontFamily(), FXUtils.DEFAULT_MONOSPACE_FONT)
|
||||
+ "\"; -fx-font-size: " + config().getFontSize() + "px;");
|
||||
Holder<Object> lastCell = new Holder<>();
|
||||
listView.setCellFactory(x -> new ListCell<Log>() {
|
||||
{
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2024 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.ui;
|
||||
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.util.Duration;
|
||||
|
||||
import java.lang.invoke.MethodHandle;
|
||||
import java.lang.invoke.MethodHandles;
|
||||
import java.lang.invoke.MethodType;
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/**
|
||||
* @author Glavo
|
||||
*/
|
||||
public class TooltipInstaller {
|
||||
public static final TooltipInstaller INSTALLER;
|
||||
|
||||
static {
|
||||
TooltipInstaller installer = null;
|
||||
|
||||
try {
|
||||
installer = new NewInstaller();
|
||||
} catch (Throwable e) {
|
||||
try {
|
||||
installer = new OldInstaller();
|
||||
} catch (Throwable e2) {
|
||||
e2.addSuppressed(e);
|
||||
LOG.warning("Failed to initialize TooltipInstaller", e2);
|
||||
}
|
||||
}
|
||||
|
||||
INSTALLER = installer != null ? installer : new TooltipInstaller();
|
||||
}
|
||||
|
||||
TooltipInstaller() {
|
||||
}
|
||||
|
||||
public void installTooltip(Node node, Duration showDelay, Duration showDuration, Duration hideDelay, Tooltip tooltip) {
|
||||
Tooltip.install(node, tooltip);
|
||||
}
|
||||
|
||||
// For Java 8
|
||||
private static final class OldInstaller extends TooltipInstaller {
|
||||
private static final Constructor<?> createTooltipBehavior;
|
||||
private static final Method installTooltipBehavior;
|
||||
|
||||
static {
|
||||
try {
|
||||
Class<?> behaviorClass = Class.forName("javafx.scene.control.Tooltip$TooltipBehavior");
|
||||
createTooltipBehavior = behaviorClass.getDeclaredConstructor(Duration.class, Duration.class, Duration.class, boolean.class);
|
||||
createTooltipBehavior.setAccessible(true);
|
||||
installTooltipBehavior = behaviorClass.getDeclaredMethod("install", Node.class, Tooltip.class);
|
||||
installTooltipBehavior.setAccessible(true);
|
||||
} catch (Exception e) {
|
||||
throw new ExceptionInInitializerError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void installTooltip(Node node, Duration showDelay, Duration showDuration, Duration hideDelay, Tooltip tooltip) {
|
||||
try {
|
||||
Object behavior = createTooltipBehavior.newInstance(showDelay, showDuration, hideDelay, false);
|
||||
installTooltipBehavior.invoke(behavior, node, tooltip);
|
||||
} catch (ReflectiveOperationException e) {
|
||||
LOG.warning("Failed to set tooltip show delay", e);
|
||||
Tooltip.install(node, tooltip);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For Java 9+
|
||||
private static final class NewInstaller extends TooltipInstaller {
|
||||
private static final MethodHandle setTooltipShowDelay;
|
||||
private static final MethodHandle setTooltipShowDuration;
|
||||
private static final MethodHandle setTooltipHideDelay;
|
||||
|
||||
static {
|
||||
try {
|
||||
MethodHandles.Lookup lookup = MethodHandles.publicLookup();
|
||||
MethodType methodType = MethodType.methodType(void.class, Duration.class);
|
||||
|
||||
setTooltipShowDelay = lookup.findVirtual(Tooltip.class, "setShowDelay", methodType);
|
||||
setTooltipShowDuration = lookup.findVirtual(Tooltip.class, "setShowDuration", methodType);
|
||||
setTooltipHideDelay = lookup.findVirtual(Tooltip.class, "setHideDelay", methodType);
|
||||
} catch (Exception e) {
|
||||
throw new ExceptionInInitializerError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void installTooltip(Node node, Duration showDelay, Duration showDuration, Duration hideDelay, Tooltip tooltip) {
|
||||
try {
|
||||
setTooltipShowDelay.invokeExact(tooltip, showDelay);
|
||||
setTooltipShowDuration.invokeExact(tooltip, showDuration);
|
||||
setTooltipHideDelay.invokeExact(tooltip, hideDelay);
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to set tooltip show delay", e);
|
||||
}
|
||||
|
||||
Tooltip.install(node, tooltip);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import javafx.scene.layout.VBox;
|
||||
import org.jackhuang.hmcl.auth.Account;
|
||||
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccount;
|
||||
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
|
||||
import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccount;
|
||||
import org.jackhuang.hmcl.game.TexturesLoader;
|
||||
import org.jackhuang.hmcl.setting.Accounts;
|
||||
import org.jackhuang.hmcl.setting.Theme;
|
||||
@@ -128,6 +129,10 @@ public final class AccountListItemSkin extends SkinBase<AccountListItem> {
|
||||
JFXButton btnRefresh = new JFXButton();
|
||||
SpinnerPane spinnerRefresh = new SpinnerPane();
|
||||
spinnerRefresh.getStyleClass().setAll("small-spinner-pane");
|
||||
if (skinnable.getAccount() instanceof MicrosoftAccount && Accounts.OAUTH_CALLBACK.getClientId().isEmpty()) {
|
||||
btnRefresh.setDisable(true);
|
||||
FXUtils.installFastTooltip(spinnerRefresh, i18n("account.methods.microsoft.snapshot"));
|
||||
}
|
||||
btnRefresh.setOnAction(e -> {
|
||||
spinnerRefresh.showSpinner();
|
||||
skinnable.refreshAsync()
|
||||
|
||||
@@ -41,6 +41,7 @@ import org.jackhuang.hmcl.ui.construct.AdvancedListItem;
|
||||
import org.jackhuang.hmcl.ui.construct.ClassTitle;
|
||||
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
|
||||
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
|
||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
import org.jackhuang.hmcl.util.javafx.BindingMapping;
|
||||
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
|
||||
import org.jackhuang.hmcl.util.platform.NativeUtils;
|
||||
@@ -48,7 +49,6 @@ import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jackhuang.hmcl.util.platform.windows.Kernel32;
|
||||
import org.jackhuang.hmcl.util.platform.windows.WinConstants;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
@@ -190,7 +190,7 @@ public final class AccountListPage extends DecoratorAnimatedPage implements Deco
|
||||
item.titleProperty().bind(title);
|
||||
String host = "";
|
||||
try {
|
||||
host = URI.create(server.getUrl()).getHost();
|
||||
host = NetworkUtils.toURI(server.getUrl()).getHost();
|
||||
} catch (IllegalArgumentException e) {
|
||||
LOG.warning("Unparsable authlib-injector server url " + server.getUrl(), e);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ import org.jackhuang.hmcl.ui.construct.DialogAware;
|
||||
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
|
||||
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
|
||||
import javax.net.ssl.SSLException;
|
||||
import java.io.IOException;
|
||||
@@ -196,7 +195,8 @@ public final class AddAuthlibInjectorServerPane extends TransitionPane implement
|
||||
lblServerName.setText(serverBeingAdded.getName());
|
||||
lblServerUrl.setText(serverBeingAdded.getUrl());
|
||||
|
||||
lblServerWarning.setVisible("http".equals(NetworkUtils.toURL(serverBeingAdded.getUrl()).getProtocol()));
|
||||
//noinspection HttpUrlsUsage
|
||||
lblServerWarning.setVisible(serverBeingAdded.getUrl().startsWith("http://"));
|
||||
|
||||
this.setContent(confirmServerPane, ContainerAnimations.SWIPE_LEFT);
|
||||
} else {
|
||||
|
||||
@@ -267,7 +267,7 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
|
||||
}).executor(true);
|
||||
};
|
||||
|
||||
if (factory instanceof OfflineAccountFactory && username != null && !USERNAME_CHECKER_PATTERN.matcher(username).matches()) {
|
||||
if (factory instanceof OfflineAccountFactory && username != null && (!USERNAME_CHECKER_PATTERN.matcher(username).matches() || username.length() > 16)) {
|
||||
JFXButton btnYes = new JFXButton(i18n("button.ok"));
|
||||
btnYes.getStyleClass().add("dialog-error");
|
||||
btnYes.setOnAction(e -> doCreate.run());
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
package org.jackhuang.hmcl.ui.animation;
|
||||
|
||||
import org.jackhuang.hmcl.setting.ConfigHolder;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
|
||||
/**
|
||||
@@ -36,7 +37,7 @@ public final class AnimationUtils {
|
||||
public static void init() {
|
||||
}
|
||||
|
||||
private static final boolean ENABLED = !ConfigHolder.config().isAnimationDisabled();
|
||||
private static final boolean ENABLED = !ConfigHolder.config().isAnimationDisabled() && FXUtils.REDUCED_MOTION != Boolean.TRUE;
|
||||
private static final boolean PLAY_WINDOW_ANIMATION = ENABLED && !OperatingSystem.CURRENT_OS.isLinuxOrBSD();
|
||||
|
||||
public static boolean isAnimationEnabled() {
|
||||
|
||||
@@ -108,6 +108,7 @@ public final class MessageDialogPane extends HBox {
|
||||
content.getChildren().setAll(textFlow);
|
||||
else {
|
||||
ScrollPane scrollPane = new ScrollPane(textFlow);
|
||||
FXUtils.smoothScrolling(scrollPane);
|
||||
scrollPane.setPrefHeight(350);
|
||||
VBox.setVgrow(scrollPane, Priority.ALWAYS);
|
||||
scrollPane.setFitToWidth(true);
|
||||
|
||||
@@ -24,11 +24,13 @@ import javafx.beans.property.*;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.ColorPicker;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.Toggle;
|
||||
import javafx.scene.control.ToggleGroup;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.scene.paint.Paint;
|
||||
import javafx.stage.FileChooser;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
@@ -297,4 +299,35 @@ public final class MultiFileItem<T> extends VBox {
|
||||
return pane;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class PaintOption<T> extends Option<T> {
|
||||
private final ColorPicker colorPicker = new ColorPicker();
|
||||
|
||||
public PaintOption(String title, T data) {
|
||||
super(title, data);
|
||||
}
|
||||
|
||||
public PaintOption<T> bindBidirectional(Property<Paint> property) {
|
||||
FXUtils.bindPaint(colorPicker, property);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Node createItem(ToggleGroup group) {
|
||||
BorderPane pane = new BorderPane();
|
||||
pane.setPadding(new Insets(3));
|
||||
FXUtils.setLimitHeight(pane, 30);
|
||||
|
||||
left.setText(title);
|
||||
BorderPane.setAlignment(left, Pos.CENTER_LEFT);
|
||||
left.setToggleGroup(group);
|
||||
left.setUserData(data);
|
||||
pane.setLeft(left);
|
||||
|
||||
colorPicker.disableProperty().bind(left.selectedProperty().not());
|
||||
BorderPane.setAlignment(colorPicker, Pos.CENTER_RIGHT);
|
||||
pane.setRight(colorPicker);
|
||||
return pane;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,13 +22,10 @@ import javafx.application.Platform;
|
||||
import javafx.beans.property.StringProperty;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ScrollPane;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.jackhuang.hmcl.task.FileDownloadTask;
|
||||
import org.jackhuang.hmcl.task.TaskExecutor;
|
||||
import org.jackhuang.hmcl.task.TaskListener;
|
||||
import org.jackhuang.hmcl.task.*;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.util.TaskCancellationAction;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
@@ -43,7 +40,7 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
public class TaskExecutorDialogPane extends BorderPane {
|
||||
private TaskExecutor executor;
|
||||
private TaskCancellationAction onCancel;
|
||||
private final Consumer<FileDownloadTask.SpeedEvent> speedEventHandler;
|
||||
private final Consumer<FetchTask.SpeedEvent> speedEventHandler;
|
||||
|
||||
private final Label lblTitle;
|
||||
private final Label lblProgress;
|
||||
@@ -61,16 +58,10 @@ public class TaskExecutorDialogPane extends BorderPane {
|
||||
lblTitle = new Label();
|
||||
lblTitle.setStyle("-fx-font-size: 14px; -fx-font-weight: BOLD;");
|
||||
|
||||
ScrollPane scrollPane = new ScrollPane();
|
||||
scrollPane.setFitToHeight(true);
|
||||
scrollPane.setFitToWidth(true);
|
||||
VBox.setVgrow(scrollPane, Priority.ALWAYS);
|
||||
{
|
||||
taskListPane = new TaskListPane();
|
||||
scrollPane.setContent(taskListPane);
|
||||
}
|
||||
taskListPane = new TaskListPane();
|
||||
VBox.setVgrow(taskListPane, Priority.ALWAYS);
|
||||
|
||||
center.getChildren().setAll(lblTitle, scrollPane);
|
||||
center.getChildren().setAll(lblTitle, taskListPane);
|
||||
}
|
||||
|
||||
BorderPane bottom = new BorderPane();
|
||||
@@ -108,7 +99,7 @@ public class TaskExecutorDialogPane extends BorderPane {
|
||||
String finalUnit = unit;
|
||||
Platform.runLater(() -> lblProgress.setText(String.format("%.1f %s", finalSpeed, finalUnit)));
|
||||
};
|
||||
FileDownloadTask.speedEvent.channel(FileDownloadTask.SpeedEvent.class).registerWeak(speedEventHandler);
|
||||
FileDownloadTask.speedEvent.channel(FetchTask.SpeedEvent.class).registerWeak(speedEventHandler);
|
||||
|
||||
onEscPressed(this, btnCancel::fire);
|
||||
}
|
||||
|
||||
@@ -17,15 +17,20 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.ui.construct;
|
||||
|
||||
import com.jfoenix.controls.JFXListView;
|
||||
import com.jfoenix.controls.JFXProgressBar;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.WeakListener;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.binding.DoubleBinding;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ListCell;
|
||||
import javafx.scene.control.ProgressIndicator;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import org.jackhuang.hmcl.download.fabric.FabricAPIInstallTask;
|
||||
@@ -52,6 +57,7 @@ import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackCompletionTask;
|
||||
import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackExportTask;
|
||||
import org.jackhuang.hmcl.mod.modrinth.ModrinthCompletionTask;
|
||||
import org.jackhuang.hmcl.mod.modrinth.ModrinthInstallTask;
|
||||
import org.jackhuang.hmcl.mod.modrinth.ModrinthModpackExportTask;
|
||||
import org.jackhuang.hmcl.mod.multimc.MultiMCModpackExportTask;
|
||||
import org.jackhuang.hmcl.mod.multimc.MultiMCModpackInstallTask;
|
||||
import org.jackhuang.hmcl.mod.server.ServerModpackCompletionTask;
|
||||
@@ -63,54 +69,86 @@ import org.jackhuang.hmcl.task.TaskExecutor;
|
||||
import org.jackhuang.hmcl.task.TaskListener;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.FXThread;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
|
||||
import static org.jackhuang.hmcl.util.Lang.tryCast;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public final class TaskListPane extends StackPane {
|
||||
private static final Insets DEFAULT_PROGRESS_NODE_PADDING = new Insets(0, 0, 8, 0);
|
||||
private static final Insets STAGED_PROGRESS_NODE_PADDING = new Insets(0, 0, 8, 26);
|
||||
|
||||
private TaskExecutor executor;
|
||||
private final AdvancedListBox listBox = new AdvancedListBox();
|
||||
private final JFXListView<Node> listView = new JFXListView<>();
|
||||
private final Map<Task<?>, ProgressListNode> nodes = new HashMap<>();
|
||||
private final List<StageNode> stageNodes = new ArrayList<>();
|
||||
private final Map<String, StageNode> stageNodes = new HashMap<>();
|
||||
private final ObjectProperty<Insets> progressNodePadding = new SimpleObjectProperty<>(Insets.EMPTY);
|
||||
private final DoubleProperty cellWidth = new SimpleDoubleProperty();
|
||||
|
||||
private Cell lastCell;
|
||||
|
||||
public TaskListPane() {
|
||||
listBox.setSpacing(0);
|
||||
listView.setPadding(new Insets(12, 0, 0, 0));
|
||||
listView.setCellFactory(l -> new Cell());
|
||||
listView.setSelectionModel(null);
|
||||
FXUtils.onChangeAndOperate(listView.widthProperty(), width -> {
|
||||
double w = width.doubleValue();
|
||||
cellWidth.set(w <= 12.0 ? w : w - 12.0);
|
||||
});
|
||||
|
||||
getChildren().setAll(listBox);
|
||||
getChildren().setAll(listView);
|
||||
}
|
||||
|
||||
@FXThread
|
||||
private void addStages(@NotNull Collection<String> stages) {
|
||||
for (String stage : stages) {
|
||||
stageNodes.computeIfAbsent(stage, s -> {
|
||||
StageNode node = new StageNode(stage);
|
||||
listView.getItems().add(node);
|
||||
return node;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@FXThread
|
||||
private void updateProgressNodePadding() {
|
||||
progressNodePadding.set(stageNodes.isEmpty() ? DEFAULT_PROGRESS_NODE_PADDING : STAGED_PROGRESS_NODE_PADDING);
|
||||
}
|
||||
|
||||
public void setExecutor(TaskExecutor executor) {
|
||||
List<String> stages = Lang.removingDuplicates(executor.getStages());
|
||||
this.executor = executor;
|
||||
executor.addTaskListener(new TaskListener() {
|
||||
@Override
|
||||
public void onStart() {
|
||||
Platform.runLater(() -> {
|
||||
stageNodes.clear();
|
||||
listBox.clear();
|
||||
stageNodes.addAll(stages.stream().map(StageNode::new).collect(Collectors.toList()));
|
||||
stageNodes.forEach(listBox::add);
|
||||
|
||||
if (stages.isEmpty()) progressNodePadding.setValue(new Insets(0, 0, 8, 0));
|
||||
else progressNodePadding.setValue(new Insets(0, 0, 8, 26));
|
||||
listView.getItems().clear();
|
||||
addStages(executor.getStages());
|
||||
updateProgressNodePadding();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReady(Task<?> task) {
|
||||
if (task instanceof Task.StagesHintTask) {
|
||||
Platform.runLater(() -> {
|
||||
addStages(((Task<?>.StagesHintTask) task).getStages());
|
||||
updateProgressNodePadding();
|
||||
});
|
||||
}
|
||||
|
||||
if (task.getStage() != null) {
|
||||
Platform.runLater(() -> {
|
||||
stageNodes.stream().filter(x -> x.stage.equals(task.getStage())).findAny().ifPresent(StageNode::begin);
|
||||
StageNode node = stageNodes.get(task.getStage());
|
||||
if (node != null)
|
||||
node.begin();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -123,6 +161,8 @@ public final class TaskListPane extends StackPane {
|
||||
if (task instanceof GameAssetDownloadTask) {
|
||||
task.setName(i18n("assets.download_all"));
|
||||
} else if (task instanceof GameInstallTask) {
|
||||
if (task.getInheritedStage() != null && task.getInheritedStage().startsWith("hmcl.install.game"))
|
||||
return;
|
||||
task.setName(i18n("install.installer.install", i18n("install.installer.game")));
|
||||
} else if (task instanceof ForgeNewInstallTask || task instanceof ForgeOldInstallTask) {
|
||||
task.setName(i18n("install.installer.install", i18n("install.installer.forge")));
|
||||
@@ -156,7 +196,7 @@ public final class TaskListPane extends StackPane {
|
||||
task.setName(i18n("install.installing") + ": " + i18n("modpack.type.server"));
|
||||
} else if (task instanceof HMCLModpackInstallTask) {
|
||||
task.setName(i18n("modpack.installing.given", i18n("modpack.type.hmcl")));
|
||||
} else if (task instanceof McbbsModpackExportTask || task instanceof MultiMCModpackExportTask || task instanceof ServerModpackExportTask) {
|
||||
} else if (task instanceof McbbsModpackExportTask || task instanceof MultiMCModpackExportTask || task instanceof ServerModpackExportTask || task instanceof ModrinthModpackExportTask) {
|
||||
task.setName(i18n("modpack.export"));
|
||||
} else if (task instanceof MinecraftInstanceTask) {
|
||||
task.setName(i18n("modpack.scan"));
|
||||
@@ -169,25 +209,25 @@ public final class TaskListPane extends StackPane {
|
||||
Platform.runLater(() -> {
|
||||
ProgressListNode node = new ProgressListNode(task);
|
||||
nodes.put(task, node);
|
||||
StageNode stageNode = stageNodes.stream().filter(x -> x.stage.equals(task.getInheritedStage())).findAny().orElse(null);
|
||||
listBox.add(listBox.indexOf(stageNode) + 1, node);
|
||||
StageNode stageNode = stageNodes.get(task.getInheritedStage());
|
||||
listView.getItems().add(listView.getItems().indexOf(stageNode) + 1, node);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFinished(Task<?> task) {
|
||||
if (task.getStage() != null) {
|
||||
Platform.runLater(() -> {
|
||||
stageNodes.stream().filter(x -> x.stage.equals(task.getStage())).findAny().ifPresent(StageNode::succeed);
|
||||
});
|
||||
}
|
||||
|
||||
Platform.runLater(() -> {
|
||||
if (task.getStage() != null) {
|
||||
StageNode stageNode = stageNodes.get(task.getStage());
|
||||
if (stageNode != null)
|
||||
stageNode.succeed();
|
||||
}
|
||||
|
||||
ProgressListNode node = nodes.remove(task);
|
||||
if (node == null)
|
||||
return;
|
||||
node.unbind();
|
||||
listBox.remove(node);
|
||||
if (node != null) {
|
||||
node.unbind();
|
||||
listView.getItems().remove(node);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -195,25 +235,23 @@ public final class TaskListPane extends StackPane {
|
||||
public void onFailed(Task<?> task, Throwable throwable) {
|
||||
if (task.getStage() != null) {
|
||||
Platform.runLater(() -> {
|
||||
stageNodes.stream().filter(x -> x.stage.equals(task.getStage())).findAny().ifPresent(StageNode::fail);
|
||||
StageNode stageNode = stageNodes.get(task.getStage());
|
||||
if (stageNode != null)
|
||||
stageNode.fail();
|
||||
});
|
||||
}
|
||||
ProgressListNode node = nodes.remove(task);
|
||||
if (node == null)
|
||||
return;
|
||||
Platform.runLater(() -> {
|
||||
node.setThrowable(throwable);
|
||||
});
|
||||
if (node != null)
|
||||
Platform.runLater(() -> node.setThrowable(throwable));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPropertiesUpdate(Task<?> task) {
|
||||
if (task instanceof Task.CountTask) {
|
||||
runInFX(() -> {
|
||||
stageNodes.stream()
|
||||
.filter(x -> x.stage.equals(((Task<?>.CountTask) task).getCountStage()))
|
||||
.findAny()
|
||||
.ifPresent(StageNode::count);
|
||||
StageNode stageNode = stageNodes.get(((Task<?>.CountTask) task).getCountStage());
|
||||
if (stageNode != null)
|
||||
stageNode.count();
|
||||
});
|
||||
|
||||
return;
|
||||
@@ -222,121 +260,251 @@ public final class TaskListPane extends StackPane {
|
||||
if (task.getStage() != null) {
|
||||
int total = tryCast(task.getProperties().get("total"), Integer.class).orElse(0);
|
||||
runInFX(() -> {
|
||||
stageNodes.stream()
|
||||
.filter(x -> x.stage.equals(task.getStage()))
|
||||
.findAny()
|
||||
.ifPresent(stageNode -> {
|
||||
stageNode.setTotal(total);
|
||||
});
|
||||
StageNode stageNode = stageNodes.get(task.getStage());
|
||||
if (stageNode != null)
|
||||
stageNode.addTotal(total);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static class StageNode extends BorderPane {
|
||||
private final String stage;
|
||||
private final class Cell extends ListCell<Node> {
|
||||
private static final double STATUS_ICON_SIZE = 14;
|
||||
|
||||
private final BorderPane pane = new BorderPane();
|
||||
private final StackPane left = new StackPane();
|
||||
private final Label title = new Label();
|
||||
private final Label message = new Label();
|
||||
private final JFXProgressBar bar = new JFXProgressBar();
|
||||
|
||||
private WeakReference<StageNode> prevStageNodeRef;
|
||||
private ChangeListener<StageNode.Status> statusChangeListener;
|
||||
|
||||
private Cell() {
|
||||
setPadding(Insets.EMPTY);
|
||||
|
||||
prefWidthProperty().bind(cellWidth);
|
||||
|
||||
FXUtils.setLimitHeight(left, STATUS_ICON_SIZE);
|
||||
FXUtils.setLimitWidth(left, STATUS_ICON_SIZE);
|
||||
|
||||
BorderPane.setAlignment(left, Pos.CENTER_LEFT);
|
||||
BorderPane.setMargin(left, new Insets(0, 12, 0, 0));
|
||||
BorderPane.setAlignment(title, Pos.CENTER_LEFT);
|
||||
pane.setCenter(title);
|
||||
|
||||
DoubleBinding barWidth = Bindings.createDoubleBinding(() -> {
|
||||
Insets padding = pane.getPadding();
|
||||
Insets insets = pane.getInsets();
|
||||
return pane.getWidth() - padding.getLeft() - padding.getRight() - insets.getLeft() - insets.getRight();
|
||||
}, pane.paddingProperty(), pane.widthProperty());
|
||||
bar.minWidthProperty().bind(barWidth);
|
||||
bar.prefWidthProperty().bind(barWidth);
|
||||
bar.maxWidthProperty().bind(barWidth);
|
||||
|
||||
setGraphic(pane);
|
||||
}
|
||||
|
||||
private void updateLeftIcon(StageNode.Status status) {
|
||||
left.getChildren().setAll(status.svg.createIcon(Theme.blackFill(), STATUS_ICON_SIZE));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateItem(Node item, boolean empty) {
|
||||
super.updateItem(item, empty);
|
||||
|
||||
// https://mail.openjdk.org/pipermail/openjfx-dev/2022-July/034764.html
|
||||
if (this == lastCell && !isVisible())
|
||||
return;
|
||||
lastCell = this;
|
||||
|
||||
pane.paddingProperty().unbind();
|
||||
title.textProperty().unbind();
|
||||
message.textProperty().unbind();
|
||||
bar.progressProperty().unbind();
|
||||
|
||||
StageNode prevStageNode;
|
||||
if (prevStageNodeRef != null && (prevStageNode = prevStageNodeRef.get()) != null)
|
||||
prevStageNode.status.removeListener(statusChangeListener);
|
||||
|
||||
if (item instanceof ProgressListNode) {
|
||||
var progressListNode = (ProgressListNode) item;
|
||||
title.setText(progressListNode.title);
|
||||
message.textProperty().bind(progressListNode.message);
|
||||
bar.progressProperty().bind(progressListNode.progress);
|
||||
|
||||
pane.paddingProperty().bind(progressNodePadding);
|
||||
pane.setLeft(null);
|
||||
pane.setRight(message);
|
||||
pane.setBottom(bar);
|
||||
} else if (item instanceof StageNode) {
|
||||
var stageNode = (StageNode) item;
|
||||
title.textProperty().bind(stageNode.title);
|
||||
message.setText("");
|
||||
bar.setProgress(ProgressIndicator.INDETERMINATE_PROGRESS);
|
||||
|
||||
pane.setPadding(Insets.EMPTY);
|
||||
pane.setLeft(left);
|
||||
pane.setRight(message);
|
||||
pane.setBottom(null);
|
||||
|
||||
updateLeftIcon(stageNode.status.get());
|
||||
if (statusChangeListener == null)
|
||||
statusChangeListener = new StatusChangeListener(this);
|
||||
stageNode.status.addListener(statusChangeListener);
|
||||
prevStageNodeRef = new WeakReference<>(stageNode);
|
||||
} else { // item == null
|
||||
title.setText("");
|
||||
message.setText("");
|
||||
bar.setProgress(ProgressIndicator.INDETERMINATE_PROGRESS);
|
||||
pane.setPadding(Insets.EMPTY);
|
||||
pane.setLeft(null);
|
||||
pane.setRight(null);
|
||||
pane.setBottom(null);
|
||||
}
|
||||
}
|
||||
|
||||
private final class StatusChangeListener implements ChangeListener<StageNode.Status>, WeakListener {
|
||||
|
||||
private final WeakReference<Cell> cellRef;
|
||||
|
||||
private StatusChangeListener(Cell cell) {
|
||||
this.cellRef = new WeakReference<>(cell);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean wasGarbageCollected() {
|
||||
return cellRef.get() == null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void changed(ObservableValue<? extends StageNode.Status> observable,
|
||||
StageNode.Status oldValue,
|
||||
StageNode.Status newValue) {
|
||||
Cell cell = cellRef.get();
|
||||
if (cell == null) {
|
||||
if (observable != null)
|
||||
observable.removeListener(this);
|
||||
return;
|
||||
}
|
||||
cell.updateLeftIcon(newValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static abstract class Node {
|
||||
|
||||
}
|
||||
|
||||
private static final class StageNode extends Node {
|
||||
private enum Status {
|
||||
WAITING(SVG.MORE_HORIZ),
|
||||
RUNNING(SVG.ARROW_FORWARD),
|
||||
SUCCESS(SVG.CHECK),
|
||||
FAILED(SVG.CLOSE);
|
||||
|
||||
private final SVG svg;
|
||||
|
||||
Status(SVG svg) {
|
||||
this.svg = svg;
|
||||
}
|
||||
}
|
||||
|
||||
private final ObjectProperty<Status> status = new SimpleObjectProperty<>(Status.WAITING);
|
||||
private final StringProperty title = new SimpleStringProperty();
|
||||
private final String message;
|
||||
private int count = 0;
|
||||
private int total = 0;
|
||||
private boolean started = false;
|
||||
|
||||
public StageNode(String stage) {
|
||||
this.stage = stage;
|
||||
private StageNode(String stage) {
|
||||
String stageKey;
|
||||
String stageValue;
|
||||
|
||||
String stageKey = StringUtils.substringBefore(stage, ':');
|
||||
String stageValue = StringUtils.substringAfter(stage, ':');
|
||||
int idx = stage.indexOf(':');
|
||||
if (idx >= 0) {
|
||||
stageKey = stage.substring(0, idx);
|
||||
stageValue = stage.substring(idx + 1);
|
||||
} else {
|
||||
stageKey = stage;
|
||||
stageValue = null;
|
||||
}
|
||||
|
||||
// CHECKSTYLE:OFF
|
||||
// @formatter:off
|
||||
switch (stageKey) {
|
||||
case "hmcl.modpack": message = i18n("install.modpack"); break;
|
||||
case "hmcl.modpack.download": message = i18n("launch.state.modpack"); break;
|
||||
case "hmcl.install.assets": message = i18n("assets.download"); break;
|
||||
case "hmcl.install.game": message = i18n("install.installer.install", i18n("install.installer.game") + " " + stageValue); break;
|
||||
case "hmcl.install.forge": message = i18n("install.installer.install", i18n("install.installer.forge") + " " + stageValue); break;
|
||||
case "hmcl.install.neoforge": message = i18n("install.installer.install", i18n("install.installer.neoforge") + " " + stageValue); break;
|
||||
case "hmcl.modpack": message = i18n("install.modpack"); break;
|
||||
case "hmcl.modpack.download": message = i18n("launch.state.modpack"); break;
|
||||
case "hmcl.install.assets": message = i18n("assets.download"); break;
|
||||
case "hmcl.install.libraries": message = i18n("libraries.download"); break;
|
||||
case "hmcl.install.game": message = i18n("install.installer.install", i18n("install.installer.game") + " " + stageValue); break;
|
||||
case "hmcl.install.forge": message = i18n("install.installer.install", i18n("install.installer.forge") + " " + stageValue); break;
|
||||
case "hmcl.install.neoforge": message = i18n("install.installer.install", i18n("install.installer.neoforge") + " " + stageValue); break;
|
||||
case "hmcl.install.liteloader": message = i18n("install.installer.install", i18n("install.installer.liteloader") + " " + stageValue); break;
|
||||
case "hmcl.install.optifine": message = i18n("install.installer.install", i18n("install.installer.optifine") + " " + stageValue); break;
|
||||
case "hmcl.install.fabric": message = i18n("install.installer.install", i18n("install.installer.fabric") + " " + stageValue); break;
|
||||
case "hmcl.install.optifine": message = i18n("install.installer.install", i18n("install.installer.optifine") + " " + stageValue); break;
|
||||
case "hmcl.install.fabric": message = i18n("install.installer.install", i18n("install.installer.fabric") + " " + stageValue); break;
|
||||
case "hmcl.install.fabric-api": message = i18n("install.installer.install", i18n("install.installer.fabric-api") + " " + stageValue); break;
|
||||
case "hmcl.install.quilt": message = i18n("install.installer.install", i18n("install.installer.quilt") + " " + stageValue); break;
|
||||
default: message = i18n(stageKey); break;
|
||||
case "hmcl.install.quilt": message = i18n("install.installer.install", i18n("install.installer.quilt") + " " + stageValue); break;
|
||||
case "hmcl.install.quilt-api": message = i18n("install.installer.install", i18n("install.installer.quilt-api") + " " + stageValue); break;
|
||||
default: message = i18n(stageKey); break;
|
||||
}
|
||||
// @formatter:on
|
||||
// CHECKSTYLE:ON
|
||||
|
||||
title.setText(message);
|
||||
BorderPane.setAlignment(title, Pos.CENTER_LEFT);
|
||||
BorderPane.setMargin(title, new Insets(0, 0, 0, 8));
|
||||
setPadding(new Insets(0, 0, 8, 4));
|
||||
setCenter(title);
|
||||
setLeft(FXUtils.limitingSize(SVG.MORE_HORIZ.createIcon(Theme.blackFill(), 14), 14, 14));
|
||||
title.set(message);
|
||||
}
|
||||
|
||||
public void begin() {
|
||||
if (started) return;
|
||||
started = true;
|
||||
setLeft(FXUtils.limitingSize(SVG.ARROW_FORWARD.createIcon(Theme.blackFill(), 14), 14, 14));
|
||||
private void begin() {
|
||||
if (status.get() == Status.WAITING) {
|
||||
status.set(Status.RUNNING);
|
||||
}
|
||||
}
|
||||
|
||||
public void fail() {
|
||||
setLeft(FXUtils.limitingSize(SVG.CLOSE.createIcon(Theme.blackFill(), 14), 14, 14));
|
||||
status.set(Status.FAILED);
|
||||
}
|
||||
|
||||
public void succeed() {
|
||||
setLeft(FXUtils.limitingSize(SVG.CHECK.createIcon(Theme.blackFill(), 14), 14, 14));
|
||||
status.set(Status.SUCCESS);
|
||||
}
|
||||
|
||||
public void count() {
|
||||
updateCounter(++count, total);
|
||||
}
|
||||
|
||||
public void setTotal(int total) {
|
||||
this.total = total;
|
||||
public void addTotal(int n) {
|
||||
this.total += n;
|
||||
updateCounter(count, total);
|
||||
}
|
||||
|
||||
public void updateCounter(int count, int total) {
|
||||
if (total > 0)
|
||||
title.setText(String.format("%s - %d/%d", message, count, total));
|
||||
else
|
||||
title.setText(message);
|
||||
title.setValue(total > 0
|
||||
? message + " - " + count + "/" + total
|
||||
: message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private class ProgressListNode extends BorderPane {
|
||||
private final JFXProgressBar bar = new JFXProgressBar();
|
||||
private final Label title = new Label();
|
||||
private final Label state = new Label();
|
||||
private final DoubleBinding binding = Bindings.createDoubleBinding(() ->
|
||||
getWidth() - getPadding().getLeft() - getPadding().getRight() - getInsets().getLeft() - getInsets().getRight(),
|
||||
paddingProperty(), widthProperty());
|
||||
private static final class ProgressListNode extends Node {
|
||||
private final String title;
|
||||
private final StringProperty message = new SimpleStringProperty("");
|
||||
private final DoubleProperty progress = new SimpleDoubleProperty(0.0);
|
||||
|
||||
public ProgressListNode(Task<?> task) {
|
||||
bar.progressProperty().bind(task.progressProperty());
|
||||
title.setText(task.getName());
|
||||
state.textProperty().bind(task.messageProperty());
|
||||
|
||||
setLeft(title);
|
||||
setRight(state);
|
||||
setBottom(bar);
|
||||
|
||||
bar.minWidthProperty().bind(binding);
|
||||
bar.prefWidthProperty().bind(binding);
|
||||
bar.maxWidthProperty().bind(binding);
|
||||
|
||||
paddingProperty().bind(progressNodePadding);
|
||||
private ProgressListNode(Task<?> task) {
|
||||
this.title = task.getName();
|
||||
message.bind(task.messageProperty());
|
||||
progress.bind(task.progressProperty());
|
||||
}
|
||||
|
||||
public void unbind() {
|
||||
bar.progressProperty().unbind();
|
||||
state.textProperty().unbind();
|
||||
message.unbind();
|
||||
progress.unbind();
|
||||
}
|
||||
|
||||
public void setThrowable(Throwable throwable) {
|
||||
unbind();
|
||||
state.setText(throwable.getLocalizedMessage());
|
||||
bar.setProgress(0);
|
||||
message.set(throwable.getLocalizedMessage());
|
||||
progress.set(0.);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,7 @@ import com.jfoenix.validation.base.ValidatorBase;
|
||||
import javafx.beans.NamedArg;
|
||||
import javafx.scene.control.TextInputControl;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.URL;
|
||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
@@ -58,9 +55,9 @@ public class URLValidator extends ValidatorBase {
|
||||
hasErrors.set(!nullable);
|
||||
else {
|
||||
try {
|
||||
new URL(textField.getText()).toURI();
|
||||
NetworkUtils.toURI(textField.getText());
|
||||
hasErrors.set(false);
|
||||
} catch (IOException | URISyntaxException e) {
|
||||
} catch (IllegalArgumentException e) {
|
||||
hasErrors.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,12 +31,13 @@ import javafx.event.EventHandler;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.input.DragEvent;
|
||||
import javafx.scene.input.KeyEvent;
|
||||
import javafx.scene.input.MouseButton;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.image.PixelReader;
|
||||
import javafx.scene.image.PixelWriter;
|
||||
import javafx.scene.image.WritableImage;
|
||||
import javafx.scene.input.*;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.paint.Paint;
|
||||
import javafx.stage.Stage;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.Launcher;
|
||||
@@ -44,6 +45,7 @@ import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDnD;
|
||||
import org.jackhuang.hmcl.setting.EnumBackgroundImage;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.account.AddAuthlibInjectorServerPane;
|
||||
@@ -55,26 +57,23 @@ import org.jackhuang.hmcl.ui.construct.Navigator;
|
||||
import org.jackhuang.hmcl.ui.construct.JFXDialogPane;
|
||||
import org.jackhuang.hmcl.ui.wizard.Refreshable;
|
||||
import org.jackhuang.hmcl.ui.wizard.WizardProvider;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URL;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.Random;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.*;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static java.util.stream.Collectors.toList;
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.newBuiltinImage;
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
import static org.jackhuang.hmcl.util.io.FileUtils.getExtension;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public class DecoratorController {
|
||||
private static final String PROPERTY_DIALOG_CLOSE_HANDLER = DecoratorController.class.getName() + ".dialog.closeListener";
|
||||
@@ -124,18 +123,13 @@ public class DecoratorController {
|
||||
|
||||
// Setup background
|
||||
decorator.setContentBackground(getBackground());
|
||||
changeBackgroundListener = o -> {
|
||||
final int currentCount = ++this.changeBackgroundCount;
|
||||
CompletableFuture.supplyAsync(this::getBackground, Schedulers.io())
|
||||
.thenAcceptAsync(background -> {
|
||||
if (this.changeBackgroundCount == currentCount)
|
||||
decorator.setContentBackground(background);
|
||||
}, Schedulers.javafx());
|
||||
};
|
||||
changeBackgroundListener = o -> updateBackground();
|
||||
WeakInvalidationListener weakListener = new WeakInvalidationListener(changeBackgroundListener);
|
||||
config().backgroundImageTypeProperty().addListener(weakListener);
|
||||
config().backgroundImageProperty().addListener(weakListener);
|
||||
config().backgroundImageUrlProperty().addListener(weakListener);
|
||||
config().backgroundPaintProperty().addListener(weakListener);
|
||||
config().backgroundImageOpacityProperty().addListener(weakListener);
|
||||
|
||||
// pass key events to current dialog / current page
|
||||
decorator.addEventFilter(KeyEvent.ANY, e -> {
|
||||
@@ -171,6 +165,17 @@ public class DecoratorController {
|
||||
// press ESC to go back
|
||||
onEscPressed(navigator, this::back);
|
||||
|
||||
// https://github.com/HMCL-dev/HMCL/issues/4290
|
||||
if (OperatingSystem.CURRENT_OS != OperatingSystem.MACOS) {
|
||||
// press F11 to toggle full screen
|
||||
navigator.addEventHandler(KeyEvent.KEY_PRESSED, e -> {
|
||||
if (e.getCode() == KeyCode.F11) {
|
||||
stage.setFullScreen(!stage.isFullScreen());
|
||||
e.consume();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// For JavaFX 12+
|
||||
MouseButton button = MouseButton.valueOf("BACK");
|
||||
@@ -196,6 +201,20 @@ public class DecoratorController {
|
||||
@SuppressWarnings("FieldCanBeLocal") // Strong reference
|
||||
private final InvalidationListener changeBackgroundListener;
|
||||
|
||||
private void updateBackground() {
|
||||
final int currentCount = ++this.changeBackgroundCount;
|
||||
Task.supplyAsync(Schedulers.io(), this::getBackground)
|
||||
.setName("Update background")
|
||||
.whenComplete(Schedulers.javafx(), (background, exception) -> {
|
||||
if (exception == null) {
|
||||
if (this.changeBackgroundCount == currentCount)
|
||||
decorator.setContentBackground(background);
|
||||
} else {
|
||||
LOG.warning("Failed to update background", exception);
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private Background getBackground() {
|
||||
EnumBackgroundImage imageType = config().getBackgroundImageType();
|
||||
|
||||
@@ -214,7 +233,7 @@ public class DecoratorController {
|
||||
String backgroundImageUrl = config().getBackgroundImageUrl();
|
||||
if (backgroundImageUrl != null) {
|
||||
try {
|
||||
image = FXUtils.loadImage(new URL(backgroundImageUrl));
|
||||
image = FXUtils.loadImage(backgroundImageUrl);
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Couldn't load background image", e);
|
||||
}
|
||||
@@ -223,13 +242,60 @@ public class DecoratorController {
|
||||
case CLASSIC:
|
||||
image = newBuiltinImage("/assets/img/background-classic.jpg");
|
||||
break;
|
||||
case TRANSLUCENT:
|
||||
case TRANSLUCENT: // Deprecated
|
||||
return new Background(new BackgroundFill(new Color(1, 1, 1, 0.5), CornerRadii.EMPTY, Insets.EMPTY));
|
||||
case PAINT:
|
||||
Paint paint = config().getBackgroundPaint();
|
||||
double opacity = Lang.clamp(0, config().getBackgroundImageOpacity(), 100) / 100.;
|
||||
if (paint instanceof Color || paint == null) {
|
||||
Color color = (Color) paint;
|
||||
if (color == null)
|
||||
color = Color.WHITE; // Default to white if no color is set
|
||||
if (opacity < 1.)
|
||||
color = new Color(color.getRed(), color.getGreen(), color.getBlue(), opacity);
|
||||
return new Background(new BackgroundFill(color, CornerRadii.EMPTY, Insets.EMPTY));
|
||||
} else {
|
||||
// TODO: Support opacity for non-color paints
|
||||
return new Background(new BackgroundFill(paint, CornerRadii.EMPTY, Insets.EMPTY));
|
||||
}
|
||||
}
|
||||
if (image == null) {
|
||||
image = loadDefaultBackgroundImage();
|
||||
}
|
||||
return new Background(new BackgroundImage(image, BackgroundRepeat.NO_REPEAT, BackgroundRepeat.NO_REPEAT, BackgroundPosition.DEFAULT, new BackgroundSize(800, 480, false, false, true, true)));
|
||||
return createBackgroundWithOpacity(image, config().getBackgroundImageOpacity());
|
||||
}
|
||||
|
||||
private Background createBackgroundWithOpacity(Image image, int opacity) {
|
||||
if (opacity <= 0) {
|
||||
return new Background(new BackgroundFill(new Color(1, 1, 1, 0), CornerRadii.EMPTY, Insets.EMPTY));
|
||||
} else if (opacity >= 100) {
|
||||
return new Background(new BackgroundImage(
|
||||
image,
|
||||
BackgroundRepeat.NO_REPEAT,
|
||||
BackgroundRepeat.NO_REPEAT,
|
||||
BackgroundPosition.DEFAULT,
|
||||
new BackgroundSize(800, 480, false, false, true, true)
|
||||
));
|
||||
} else {
|
||||
WritableImage tempImage = new WritableImage((int) image.getWidth(), (int) image.getHeight());
|
||||
PixelReader pixelReader = image.getPixelReader();
|
||||
PixelWriter pixelWriter = tempImage.getPixelWriter();
|
||||
for (int y = 0; y < image.getHeight(); y++) {
|
||||
for (int x = 0; x < image.getWidth(); x++) {
|
||||
Color color = pixelReader.getColor(x, y);
|
||||
Color newColor = new Color(color.getRed(), color.getGreen(), color.getBlue(), color.getOpacity() * opacity / 100);
|
||||
pixelWriter.setColor(x, y, newColor);
|
||||
}
|
||||
}
|
||||
|
||||
return new Background(new BackgroundImage(
|
||||
tempImage,
|
||||
BackgroundRepeat.NO_REPEAT,
|
||||
BackgroundRepeat.NO_REPEAT,
|
||||
BackgroundPosition.DEFAULT,
|
||||
new BackgroundSize(800, 480, false, false, true, true)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,8 +18,11 @@
|
||||
package org.jackhuang.hmcl.ui.decorator;
|
||||
|
||||
import com.jfoenix.controls.JFXButton;
|
||||
import javafx.beans.InvalidationListener;
|
||||
import javafx.beans.WeakInvalidationListener;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.collections.ListChangeListener;
|
||||
import javafx.event.EventHandler;
|
||||
import javafx.geometry.Bounds;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
@@ -29,6 +32,7 @@ import javafx.scene.control.Label;
|
||||
import javafx.scene.control.SkinBase;
|
||||
import javafx.scene.effect.BlurType;
|
||||
import javafx.scene.effect.DropShadow;
|
||||
import javafx.scene.input.MouseButton;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.scene.paint.Color;
|
||||
@@ -43,6 +47,7 @@ import org.jackhuang.hmcl.ui.animation.AnimationProducer;
|
||||
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
|
||||
import org.jackhuang.hmcl.ui.animation.TransitionPane;
|
||||
import org.jackhuang.hmcl.ui.wizard.Navigation;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
|
||||
public class DecoratorSkin extends SkinBase<Decorator> {
|
||||
private final StackPane root, parent;
|
||||
@@ -50,6 +55,8 @@ public class DecoratorSkin extends SkinBase<Decorator> {
|
||||
private final Stage primaryStage;
|
||||
private final TransitionPane navBarPane;
|
||||
|
||||
private final InvalidationListener onWindowsStatusChange;
|
||||
|
||||
private double mouseInitX, mouseInitY, stageInitX, stageInitY, stageInitWidth, stageInitHeight;
|
||||
|
||||
/**
|
||||
@@ -80,9 +87,34 @@ public class DecoratorSkin extends SkinBase<Decorator> {
|
||||
|
||||
skinnable.getSnackbar().registerSnackbarContainer(parent);
|
||||
|
||||
root.addEventFilter(MouseEvent.MOUSE_RELEASED, this::onMouseReleased);
|
||||
root.addEventFilter(MouseEvent.MOUSE_DRAGGED, this::onMouseDragged);
|
||||
root.addEventFilter(MouseEvent.MOUSE_MOVED, this::onMouseMoved);
|
||||
EventHandler<MouseEvent> onMouseReleased = this::onMouseReleased;
|
||||
EventHandler<MouseEvent> onMouseDragged = this::onMouseDragged;
|
||||
EventHandler<MouseEvent> onMouseMoved = this::onMouseMoved;
|
||||
|
||||
// https://github.com/HMCL-dev/HMCL/issues/4290
|
||||
if (OperatingSystem.CURRENT_OS != OperatingSystem.MACOS) {
|
||||
onWindowsStatusChange = observable -> {
|
||||
if (primaryStage.isIconified() || primaryStage.isFullScreen() || primaryStage.isMaximized()) {
|
||||
root.removeEventFilter(MouseEvent.MOUSE_RELEASED, onMouseReleased);
|
||||
root.removeEventFilter(MouseEvent.MOUSE_DRAGGED, onMouseDragged);
|
||||
root.removeEventFilter(MouseEvent.MOUSE_MOVED, onMouseMoved);
|
||||
} else {
|
||||
root.addEventFilter(MouseEvent.MOUSE_RELEASED, onMouseReleased);
|
||||
root.addEventFilter(MouseEvent.MOUSE_DRAGGED, onMouseDragged);
|
||||
root.addEventFilter(MouseEvent.MOUSE_MOVED, onMouseMoved);
|
||||
}
|
||||
};
|
||||
WeakInvalidationListener weakOnWindowsStatusChange = new WeakInvalidationListener(onWindowsStatusChange);
|
||||
primaryStage.iconifiedProperty().addListener(weakOnWindowsStatusChange);
|
||||
primaryStage.maximizedProperty().addListener(weakOnWindowsStatusChange);
|
||||
primaryStage.fullScreenProperty().addListener(weakOnWindowsStatusChange);
|
||||
onWindowsStatusChange.invalidated(null);
|
||||
} else {
|
||||
onWindowsStatusChange = null;
|
||||
root.addEventFilter(MouseEvent.MOUSE_RELEASED, onMouseReleased);
|
||||
root.addEventFilter(MouseEvent.MOUSE_DRAGGED, onMouseDragged);
|
||||
root.addEventFilter(MouseEvent.MOUSE_MOVED, onMouseMoved);
|
||||
}
|
||||
|
||||
shadowContainer.getChildren().setAll(parent);
|
||||
root.getChildren().setAll(shadowContainer);
|
||||
@@ -156,6 +188,16 @@ public class DecoratorSkin extends SkinBase<Decorator> {
|
||||
BorderPane titleBar = new BorderPane();
|
||||
titleContainer.getChildren().add(titleBar);
|
||||
|
||||
// https://github.com/HMCL-dev/HMCL/issues/4290
|
||||
if (OperatingSystem.CURRENT_OS != OperatingSystem.MACOS) {
|
||||
titleBar.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> {
|
||||
if (event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2) {
|
||||
primaryStage.setMaximized(!primaryStage.isMaximized());
|
||||
event.consume();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Rectangle buttonsContainerPlaceHolder = new Rectangle();
|
||||
{
|
||||
navBarPane = new TransitionPane();
|
||||
|
||||
@@ -121,14 +121,19 @@ public abstract class AbstractInstallersPage extends Control implements WizardPa
|
||||
InstallerItem[] libraries = control.group.getLibraries();
|
||||
|
||||
FlowPane libraryPane = new FlowPane(libraries);
|
||||
libraryPane.setVgap(8);
|
||||
libraryPane.setVgap(16);
|
||||
libraryPane.setHgap(16);
|
||||
|
||||
ScrollPane scrollPane = new ScrollPane(libraryPane);
|
||||
scrollPane.setFitToWidth(true);
|
||||
scrollPane.setFitToHeight(true);
|
||||
BorderPane.setMargin(scrollPane, new Insets(16, 0, 16, 0));
|
||||
root.setCenter(scrollPane);
|
||||
if (libraries.length <= 8) {
|
||||
BorderPane.setMargin(libraryPane, new Insets(16, 0, 16, 0));
|
||||
root.setCenter(libraryPane);
|
||||
} else {
|
||||
ScrollPane scrollPane = new ScrollPane(libraryPane);
|
||||
scrollPane.setFitToWidth(true);
|
||||
scrollPane.setFitToHeight(true);
|
||||
BorderPane.setMargin(scrollPane, new Insets(16, 0, 16, 0));
|
||||
root.setCenter(scrollPane);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
|
||||
@@ -52,7 +52,6 @@ import org.jackhuang.hmcl.ui.wizard.Navigation;
|
||||
import org.jackhuang.hmcl.ui.wizard.WizardController;
|
||||
import org.jackhuang.hmcl.ui.wizard.WizardProvider;
|
||||
import org.jackhuang.hmcl.util.TaskCancellationAction;
|
||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
@@ -156,7 +155,7 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
|
||||
Path dest = runDirectory.resolve(subdirectoryName).resolve(result);
|
||||
|
||||
Controllers.taskDialog(Task.composeAsync(() -> {
|
||||
FileDownloadTask task = new FileDownloadTask(NetworkUtils.toURL(file.getFile().getUrl()), dest.toFile());
|
||||
var task = new FileDownloadTask(file.getFile().getUrl(), dest);
|
||||
task.setName(file.getName());
|
||||
return task;
|
||||
}).whenComplete(Schedulers.javafx(), exception -> {
|
||||
|
||||
@@ -27,7 +27,6 @@ import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
|
||||
import org.jackhuang.hmcl.ui.construct.RequiredValidator;
|
||||
import org.jackhuang.hmcl.ui.construct.Validator;
|
||||
import org.jackhuang.hmcl.ui.wizard.WizardController;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@@ -77,11 +76,24 @@ public class InstallersPage extends AbstractInstallersPage {
|
||||
public void cleanup(Map<String, Object> settings) {
|
||||
}
|
||||
|
||||
private static boolean checkName(String name) {
|
||||
for (int i = 0; i < name.length(); i++) {
|
||||
char c = name.charAt(i);
|
||||
if (!(c >= '0' && c <= '9')
|
||||
&& !(c >= 'a' && c <= 'z')
|
||||
&& !(c >= 'A' && c <= 'Z')
|
||||
&& c != '-' && c != '_' && c != '.'
|
||||
)
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected void onInstall() {
|
||||
String name = txtName.getText();
|
||||
|
||||
// Check for non-ASCII characters.
|
||||
if (!StringUtils.isASCII(name)) {
|
||||
if (!checkName(name)) {
|
||||
Controllers.dialog(new MessageDialogPane.Builder(
|
||||
i18n("install.name.invalid"),
|
||||
i18n("message.warning"),
|
||||
|
||||
@@ -18,11 +18,11 @@
|
||||
package org.jackhuang.hmcl.ui.download;
|
||||
|
||||
import javafx.scene.Node;
|
||||
import org.jackhuang.hmcl.game.ModpackHelper;
|
||||
import org.jackhuang.hmcl.game.ManuallyCreatedModpackException;
|
||||
import org.jackhuang.hmcl.mod.ModpackCompletionException;
|
||||
import org.jackhuang.hmcl.game.ModpackHelper;
|
||||
import org.jackhuang.hmcl.mod.MismatchedModpackTypeException;
|
||||
import org.jackhuang.hmcl.mod.Modpack;
|
||||
import org.jackhuang.hmcl.mod.ModpackCompletionException;
|
||||
import org.jackhuang.hmcl.mod.UnsupportedModpackException;
|
||||
import org.jackhuang.hmcl.mod.server.ServerModpackManifest;
|
||||
import org.jackhuang.hmcl.setting.Profile;
|
||||
@@ -102,7 +102,7 @@ public class ModpackInstallWizardProvider implements WizardProvider {
|
||||
} catch (UnsupportedModpackException | ManuallyCreatedModpackException e) {
|
||||
Controllers.dialog(i18n("modpack.unsupported"), i18n("message.error"), MessageType.ERROR);
|
||||
} catch (MismatchedModpackTypeException e) {
|
||||
Controllers.dialog(i18n("modpack.mismatched_type"), i18n("message.error"), MessageType.ERROR);
|
||||
Controllers.dialog(i18n("modpack.mismatched_type", e.getRequired(), e.getFound()), i18n("message.error"), MessageType.ERROR);
|
||||
} catch (IOException e) {
|
||||
Controllers.dialog(i18n("modpack.invalid"), i18n("message.error"), MessageType.ERROR);
|
||||
}
|
||||
|
||||
@@ -29,9 +29,7 @@ import javafx.scene.shape.SVGPath;
|
||||
import javafx.stage.FileChooser;
|
||||
import org.jackhuang.hmcl.game.ModpackHelper;
|
||||
import org.jackhuang.hmcl.mod.server.ServerModpackManifest;
|
||||
import org.jackhuang.hmcl.task.FileDownloadTask;
|
||||
import org.jackhuang.hmcl.task.GetTask;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.*;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
@@ -43,7 +41,6 @@ import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.net.URL;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
@@ -126,10 +123,9 @@ public final class ModpackSelectionPage extends VBox implements WizardPage {
|
||||
}
|
||||
|
||||
private void onChooseRemoteFile() {
|
||||
Controllers.prompt(i18n("modpack.choose.remote.tooltip"), (urlString, resolve, reject) -> {
|
||||
Controllers.prompt(i18n("modpack.choose.remote.tooltip"), (url, resolve, reject) -> {
|
||||
try {
|
||||
URL url = new URL(urlString);
|
||||
if (urlString.endsWith("server-manifest.json")) {
|
||||
if (url.endsWith("server-manifest.json")) {
|
||||
// if urlString ends with .json, we assume that the url is server-manifest.json
|
||||
Controllers.taskDialog(new GetTask(url).whenComplete(Schedulers.javafx(), (result, e) -> {
|
||||
ServerModpackManifest manifest = JsonUtils.fromMaybeMalformedJson(result, ServerModpackManifest.class);
|
||||
@@ -150,7 +146,7 @@ public final class ModpackSelectionPage extends VBox implements WizardPage {
|
||||
resolve.run();
|
||||
|
||||
Controllers.taskDialog(
|
||||
new FileDownloadTask(url, modpack.toFile(), null)
|
||||
new FileDownloadTask(url, modpack)
|
||||
.whenComplete(Schedulers.javafx(), e -> {
|
||||
if (e == null) {
|
||||
resolve.run();
|
||||
|
||||
+10
-9
@@ -37,7 +37,7 @@ import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.net.URL;
|
||||
import java.net.URI;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -85,6 +85,7 @@ public final class UpdateInstallerWizardProvider implements WizardProvider {
|
||||
ret = ret.thenComposeAsync(version -> dependencyManager.installLibraryAsync(version, remoteVersion));
|
||||
stages.add(String.format("hmcl.install.%s:%s", remoteVersion.getLibraryId(), remoteVersion.getSelfVersion()));
|
||||
if ("game".equals(remoteVersion.getLibraryId())) {
|
||||
stages.add("hmcl.install.libraries");
|
||||
stages.add("hmcl.install.assets");
|
||||
}
|
||||
} else if (value instanceof RemoveVersionAction) {
|
||||
@@ -133,28 +134,28 @@ public final class UpdateInstallerWizardProvider implements WizardProvider {
|
||||
if (exception.getCause() instanceof ResponseCodeException) {
|
||||
ResponseCodeException rce = (ResponseCodeException) exception.getCause();
|
||||
int responseCode = rce.getResponseCode();
|
||||
URL url = rce.getUrl();
|
||||
String uri = rce.getUri();
|
||||
if (responseCode == 404)
|
||||
message += i18n("download.code.404", url);
|
||||
message += i18n("download.code.404", uri);
|
||||
else
|
||||
message += i18n("download.failed", url, responseCode);
|
||||
message += i18n("download.failed", uri, responseCode);
|
||||
} else {
|
||||
message += StringUtils.getStackTrace(exception.getCause());
|
||||
}
|
||||
Controllers.dialog(message, i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
|
||||
} else if (exception instanceof DownloadException) {
|
||||
URL url = ((DownloadException) exception).getUrl();
|
||||
URI uri = ((DownloadException) exception).getUri();
|
||||
if (exception.getCause() instanceof SocketTimeoutException) {
|
||||
Controllers.dialog(i18n("install.failed.downloading.timeout", url), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
|
||||
Controllers.dialog(i18n("install.failed.downloading.timeout", uri), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
|
||||
} else if (exception.getCause() instanceof ResponseCodeException) {
|
||||
ResponseCodeException responseCodeException = (ResponseCodeException) exception.getCause();
|
||||
if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) {
|
||||
Controllers.dialog(i18n("download.code." + responseCodeException.getResponseCode(), url), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
|
||||
Controllers.dialog(i18n("download.code." + responseCodeException.getResponseCode(), uri), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
|
||||
} else {
|
||||
Controllers.dialog(i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(exception.getCause()), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
|
||||
Controllers.dialog(i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause()), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
|
||||
}
|
||||
} else {
|
||||
Controllers.dialog(i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(exception.getCause()), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
|
||||
Controllers.dialog(i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause()), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
|
||||
}
|
||||
} else if (exception instanceof UnsupportedInstallationException) {
|
||||
switch (((UnsupportedInstallationException) exception).getReason()) {
|
||||
|
||||
@@ -17,24 +17,17 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.ui.download;
|
||||
|
||||
import com.jfoenix.controls.JFXButton;
|
||||
import com.jfoenix.controls.JFXCheckBox;
|
||||
import com.jfoenix.controls.JFXListView;
|
||||
import com.jfoenix.controls.JFXSpinner;
|
||||
import com.jfoenix.controls.JFXTextField;
|
||||
import javafx.animation.PauseTransition;
|
||||
import javafx.application.Platform;
|
||||
import com.jfoenix.controls.*;
|
||||
import javafx.beans.InvalidationListener;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.beans.property.StringProperty;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ListCell;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.input.KeyCode;
|
||||
import javafx.scene.input.KeyEvent;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.download.RemoteVersion;
|
||||
import org.jackhuang.hmcl.download.VersionList;
|
||||
@@ -49,295 +42,73 @@ import org.jackhuang.hmcl.download.quilt.QuiltAPIRemoteVersion;
|
||||
import org.jackhuang.hmcl.download.quilt.QuiltRemoteVersion;
|
||||
import org.jackhuang.hmcl.setting.Theme;
|
||||
import org.jackhuang.hmcl.setting.VersionIconType;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
|
||||
import org.jackhuang.hmcl.ui.animation.TransitionPane;
|
||||
import org.jackhuang.hmcl.ui.construct.ComponentList;
|
||||
import org.jackhuang.hmcl.ui.construct.HintPane;
|
||||
import org.jackhuang.hmcl.ui.construct.IconedTwoLineListItem;
|
||||
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
|
||||
import org.jackhuang.hmcl.ui.construct.*;
|
||||
import org.jackhuang.hmcl.ui.wizard.Navigation;
|
||||
import org.jackhuang.hmcl.ui.wizard.Refreshable;
|
||||
import org.jackhuang.hmcl.ui.wizard.WizardPage;
|
||||
import org.jackhuang.hmcl.util.Holder;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.ignoreEvent;
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
|
||||
import static org.jackhuang.hmcl.ui.ToolbarListPageSkin.wrap;
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.*;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public final class VersionsPage extends BorderPane implements WizardPage, Refreshable {
|
||||
public final class VersionsPage extends Control implements WizardPage, Refreshable {
|
||||
private final String gameVersion;
|
||||
private final String libraryId;
|
||||
private final String title;
|
||||
private final Navigation navigation;
|
||||
|
||||
private final JFXListView<RemoteVersion> list;
|
||||
private final JFXSpinner spinner;
|
||||
private final StackPane failedPane;
|
||||
private final StackPane emptyPane;
|
||||
private final TransitionPane root;
|
||||
private final JFXCheckBox chkRelease;
|
||||
private final JFXCheckBox chkSnapshot;
|
||||
private final JFXCheckBox chkOld;
|
||||
private final ComponentList centrePane;
|
||||
private final StackPane center;
|
||||
|
||||
private final VersionList<?> versionList;
|
||||
private CompletableFuture<?> executor;
|
||||
private final Runnable callback;
|
||||
|
||||
private final HBox searchBar;
|
||||
private final StringProperty queryString = new SimpleStringProperty();
|
||||
private final ObservableList<RemoteVersion> versions = FXCollections.observableArrayList();
|
||||
private final ObjectProperty<Status> status = new SimpleObjectProperty<>(Status.LOADING);
|
||||
|
||||
public VersionsPage(Navigation navigation, String title, String gameVersion, DownloadProvider downloadProvider, String libraryId, Runnable callback) {
|
||||
this.title = title;
|
||||
this.gameVersion = gameVersion;
|
||||
this.libraryId = libraryId;
|
||||
this.navigation = navigation;
|
||||
|
||||
HintPane hintPane = new HintPane();
|
||||
hintPane.setText(i18n("sponsor.bmclapi"));
|
||||
hintPane.getStyleClass().add("sponsor-pane");
|
||||
FXUtils.onClicked(hintPane, this::onSponsor);
|
||||
BorderPane.setMargin(hintPane, new Insets(10, 10, 0, 10));
|
||||
this.setTop(hintPane);
|
||||
|
||||
root = new TransitionPane();
|
||||
BorderPane toolbarPane = new BorderPane();
|
||||
JFXButton btnRefresh;
|
||||
{
|
||||
spinner = new JFXSpinner();
|
||||
|
||||
center = new StackPane();
|
||||
center.setStyle("-fx-padding: 10;");
|
||||
{
|
||||
centrePane = new ComponentList();
|
||||
centrePane.getStyleClass().add("no-padding");
|
||||
{
|
||||
HBox checkPane = new HBox();
|
||||
checkPane.setSpacing(10);
|
||||
{
|
||||
chkRelease = new JFXCheckBox(i18n("version.game.releases"));
|
||||
chkRelease.setSelected(true);
|
||||
HBox.setMargin(chkRelease, new Insets(10, 0, 10, 0));
|
||||
|
||||
chkSnapshot = new JFXCheckBox(i18n("version.game.snapshots"));
|
||||
HBox.setMargin(chkSnapshot, new Insets(10, 0, 10, 0));
|
||||
|
||||
chkOld = new JFXCheckBox(i18n("version.game.old"));
|
||||
HBox.setMargin(chkOld, new Insets(10, 0, 10, 0));
|
||||
|
||||
checkPane.getChildren().setAll(chkRelease, chkSnapshot, chkOld);
|
||||
}
|
||||
|
||||
list = new JFXListView<>();
|
||||
list.getStyleClass().add("jfx-list-view-float");
|
||||
VBox.setVgrow(list, Priority.ALWAYS);
|
||||
|
||||
TransitionPane rightToolbarPane = new TransitionPane();
|
||||
{
|
||||
HBox refreshPane = new HBox();
|
||||
refreshPane.setAlignment(Pos.CENTER_RIGHT);
|
||||
|
||||
btnRefresh = new JFXButton(i18n("button.refresh"));
|
||||
btnRefresh.getStyleClass().add("jfx-tool-bar-button");
|
||||
btnRefresh.setOnAction(e -> onRefresh());
|
||||
|
||||
JFXButton btnSearch = new JFXButton(i18n("search"));
|
||||
btnSearch.getStyleClass().add("jfx-tool-bar-button");
|
||||
btnSearch.setGraphic(wrap(SVG.SEARCH.createIcon(Theme.blackFill(), -1)));
|
||||
|
||||
searchBar = new HBox();
|
||||
{
|
||||
searchBar.setAlignment(Pos.CENTER);
|
||||
searchBar.setPadding(new Insets(0, 5, 0, 0));
|
||||
|
||||
JFXTextField searchField = new JFXTextField();
|
||||
searchField.setPromptText(i18n("search"));
|
||||
HBox.setHgrow(searchField, Priority.ALWAYS);
|
||||
|
||||
JFXButton closeSearchBar = new JFXButton();
|
||||
closeSearchBar.getStyleClass().add("jfx-tool-bar-button");
|
||||
closeSearchBar.setGraphic(wrap(SVG.CLOSE.createIcon(Theme.blackFill(), -1)));
|
||||
closeSearchBar.setOnAction(e -> {
|
||||
searchField.clear();
|
||||
rightToolbarPane.setContent(refreshPane, ContainerAnimations.FADE);
|
||||
});
|
||||
onEscPressed(searchField, closeSearchBar::fire);
|
||||
PauseTransition pause = new PauseTransition(Duration.millis(100));
|
||||
pause.setOnFinished(e -> queryString.set(searchField.getText()));
|
||||
searchField.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||
pause.setRate(1);
|
||||
pause.playFromStart();
|
||||
});
|
||||
|
||||
searchBar.getChildren().setAll(searchField, closeSearchBar);
|
||||
|
||||
btnSearch.setOnAction(e -> {
|
||||
rightToolbarPane.setContent(searchBar, ContainerAnimations.FADE);
|
||||
searchField.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
refreshPane.getChildren().setAll(new HBox(btnSearch, btnRefresh));
|
||||
rightToolbarPane.setContent(refreshPane, ContainerAnimations.NONE);
|
||||
}
|
||||
|
||||
// ListViewBehavior would consume ESC pressed event, preventing us from handling it, so we ignore it here
|
||||
ignoreEvent(list, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
|
||||
|
||||
toolbarPane.setLeft(checkPane);
|
||||
toolbarPane.setRight(rightToolbarPane);
|
||||
|
||||
centrePane.getContent().setAll(toolbarPane, list);
|
||||
}
|
||||
|
||||
center.getChildren().setAll(centrePane);
|
||||
}
|
||||
|
||||
failedPane = new StackPane();
|
||||
failedPane.getStyleClass().add("notice-pane");
|
||||
{
|
||||
Label label = new Label(i18n("download.failed.refresh"));
|
||||
FXUtils.onClicked(label, this::onRefresh);
|
||||
|
||||
failedPane.getChildren().setAll(label);
|
||||
}
|
||||
|
||||
emptyPane = new StackPane();
|
||||
emptyPane.getStyleClass().add("notice-pane");
|
||||
{
|
||||
Label label = new Label(i18n("download.failed.empty"));
|
||||
FXUtils.onClicked(label, this::onBack);
|
||||
|
||||
emptyPane.getChildren().setAll(label);
|
||||
}
|
||||
}
|
||||
this.setCenter(root);
|
||||
|
||||
versionList = downloadProvider.getVersionListById(libraryId);
|
||||
boolean hasType = versionList.hasType();
|
||||
chkRelease.setManaged(hasType);
|
||||
chkRelease.setVisible(hasType);
|
||||
chkSnapshot.setManaged(hasType);
|
||||
chkSnapshot.setVisible(hasType);
|
||||
chkOld.setManaged(hasType);
|
||||
chkOld.setVisible(hasType);
|
||||
|
||||
if (hasType) {
|
||||
centrePane.getContent().setAll(toolbarPane, list);
|
||||
} else {
|
||||
centrePane.getContent().setAll(list);
|
||||
}
|
||||
ComponentList.setVgrow(list, Priority.ALWAYS);
|
||||
|
||||
InvalidationListener listener = o -> {
|
||||
List<RemoteVersion> versions = loadVersions();
|
||||
String query = queryString.get();
|
||||
if (!StringUtils.isBlank(query)) {
|
||||
Predicate<RemoteVersion> predicate;
|
||||
if (query.startsWith("regex:")) {
|
||||
try {
|
||||
Pattern pattern = Pattern.compile(query.substring("regex:".length()));
|
||||
predicate = it -> pattern.matcher(it.getSelfVersion()).find();
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Illegal regular expression", e);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
String lowerQueryString = query.toLowerCase(Locale.ROOT);
|
||||
predicate = it -> it.getSelfVersion().toLowerCase(Locale.ROOT).contains(lowerQueryString);
|
||||
}
|
||||
|
||||
versions = versions.stream().filter(predicate).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
list.getItems().setAll(versions);
|
||||
};
|
||||
chkRelease.selectedProperty().addListener(listener);
|
||||
chkSnapshot.selectedProperty().addListener(listener);
|
||||
chkOld.selectedProperty().addListener(listener);
|
||||
queryString.addListener(listener);
|
||||
|
||||
btnRefresh.setGraphic(wrap(SVG.REFRESH.createIcon(Theme.blackFill(), -1)));
|
||||
|
||||
Holder<RemoteVersionListCell> lastCell = new Holder<>();
|
||||
list.setCellFactory(listView -> new RemoteVersionListCell(lastCell, libraryId));
|
||||
|
||||
FXUtils.onClicked(list, () -> {
|
||||
if (list.getSelectionModel().getSelectedIndex() < 0)
|
||||
return;
|
||||
navigation.getSettings().put(libraryId, list.getSelectionModel().getSelectedItem());
|
||||
callback.run();
|
||||
});
|
||||
this.versionList = downloadProvider.getVersionListById(libraryId);
|
||||
this.callback = callback;
|
||||
|
||||
refresh();
|
||||
}
|
||||
|
||||
private List<RemoteVersion> loadVersions() {
|
||||
return versionList.getVersions(gameVersion).stream()
|
||||
.filter(it -> {
|
||||
switch (it.getVersionType()) {
|
||||
case RELEASE:
|
||||
return chkRelease.isSelected();
|
||||
case PENDING:
|
||||
case SNAPSHOT:
|
||||
return chkSnapshot.isSelected();
|
||||
case OLD:
|
||||
return chkOld.isSelected();
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
})
|
||||
.sorted().collect(Collectors.toList());
|
||||
@Override
|
||||
protected Skin<?> createDefaultSkin() {
|
||||
return new VersionsPageSkin(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void refresh() {
|
||||
VersionList<?> currentVersionList = versionList;
|
||||
root.setContent(spinner, ContainerAnimations.FADE);
|
||||
executor = currentVersionList.refreshAsync(gameVersion).whenComplete((result, exception) -> {
|
||||
if (exception == null) {
|
||||
List<RemoteVersion> items = loadVersions();
|
||||
|
||||
Platform.runLater(() -> {
|
||||
if (versionList != currentVersionList) return;
|
||||
if (currentVersionList.getVersions(gameVersion).isEmpty()) {
|
||||
root.setContent(emptyPane, ContainerAnimations.FADE);
|
||||
status.set(Status.LOADING);
|
||||
Task<?> task = versionList.refreshAsync(gameVersion)
|
||||
.thenSupplyAsync(() -> versionList.getVersions(gameVersion).stream().sorted().collect(Collectors.toList()))
|
||||
.whenComplete(Schedulers.javafx(), (items, exception) -> {
|
||||
if (exception == null) {
|
||||
versions.setAll(items);
|
||||
status.set(Status.SUCCESS);
|
||||
} else {
|
||||
if (items.isEmpty()) {
|
||||
chkRelease.setSelected(true);
|
||||
chkSnapshot.setSelected(true);
|
||||
chkOld.setSelected(true);
|
||||
} else {
|
||||
list.getItems().setAll(items);
|
||||
}
|
||||
root.setContent(center, ContainerAnimations.FADE);
|
||||
LOG.warning("Failed to fetch versions list", exception);
|
||||
status.set(Status.FAILED);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
LOG.warning("Failed to fetch versions list", exception);
|
||||
Platform.runLater(() -> {
|
||||
if (versionList != currentVersionList) return;
|
||||
root.setContent(failedPane, ContainerAnimations.FADE);
|
||||
});
|
||||
}
|
||||
|
||||
// https://github.com/HMCL-dev/HMCL/issues/938
|
||||
System.gc();
|
||||
});
|
||||
task.start();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -348,8 +119,6 @@ public final class VersionsPage extends BorderPane implements WizardPage, Refres
|
||||
@Override
|
||||
public void cleanup(Map<String, Object> settings) {
|
||||
settings.remove(libraryId);
|
||||
if (executor != null)
|
||||
executor.cancel(true);
|
||||
}
|
||||
|
||||
private void onRefresh() {
|
||||
@@ -364,10 +133,24 @@ public final class VersionsPage extends BorderPane implements WizardPage, Refres
|
||||
FXUtils.openLink("https://bmclapidoc.bangbang93.com");
|
||||
}
|
||||
|
||||
private enum Status {
|
||||
LOADING,
|
||||
FAILED,
|
||||
SUCCESS,
|
||||
}
|
||||
|
||||
private enum VersionTypeFilter {
|
||||
ALL,
|
||||
RELEASE,
|
||||
SNAPSHOTS,
|
||||
APRIL_FOOLS,
|
||||
OLD
|
||||
}
|
||||
|
||||
private static class RemoteVersionListCell extends ListCell<RemoteVersion> {
|
||||
final IconedTwoLineListItem content = new IconedTwoLineListItem();
|
||||
final RipplerContainer ripplerContainer = new RipplerContainer(content);
|
||||
final StackPane pane = new StackPane();
|
||||
private final IconedTwoLineListItem content = new IconedTwoLineListItem();
|
||||
private final RipplerContainer ripplerContainer = new RipplerContainer(content);
|
||||
private final StackPane pane = new StackPane();
|
||||
|
||||
private final Holder<RemoteVersionListCell> lastCell;
|
||||
|
||||
@@ -406,22 +189,30 @@ public final class VersionsPage extends BorderPane implements WizardPage, Refres
|
||||
}
|
||||
|
||||
if (remoteVersion instanceof GameRemoteVersion) {
|
||||
switch (remoteVersion.getVersionType()) {
|
||||
RemoteVersion.Type versionType = remoteVersion.getVersionType();
|
||||
String wikiSuffix = getWikiUrlSuffix(remoteVersion.getGameVersion());
|
||||
switch (versionType) {
|
||||
case RELEASE:
|
||||
content.getTags().setAll(i18n("version.game.release"));
|
||||
content.setImage(VersionIconType.GRASS.getIcon());
|
||||
content.setExternalLink(i18n("wiki.version.game.release", remoteVersion.getGameVersion()));
|
||||
content.setExternalLink(i18n("wiki.version.game", wikiSuffix));
|
||||
break;
|
||||
case PENDING:
|
||||
case SNAPSHOT:
|
||||
content.getTags().setAll(i18n("version.game.snapshot"));
|
||||
content.setImage(VersionIconType.COMMAND.getIcon());
|
||||
content.setExternalLink(i18n("wiki.version.game.snapshot", remoteVersion.getGameVersion()));
|
||||
if (versionType == RemoteVersion.Type.SNAPSHOT
|
||||
&& GameVersionNumber.asGameVersion(remoteVersion.getGameVersion()).isAprilFools()) {
|
||||
content.getTags().setAll(i18n("version.game.april_fools"));
|
||||
content.setImage(VersionIconType.APRIL_FOOLS.getIcon());
|
||||
} else {
|
||||
content.getTags().setAll(i18n("version.game.snapshot"));
|
||||
content.setImage(VersionIconType.COMMAND.getIcon());
|
||||
}
|
||||
content.setExternalLink(i18n("wiki.version.game", wikiSuffix));
|
||||
break;
|
||||
default:
|
||||
content.getTags().setAll(i18n("version.game.old"));
|
||||
content.setImage(VersionIconType.CRAFT_TABLE.getIcon());
|
||||
content.setExternalLink(null);
|
||||
content.setExternalLink(i18n("wiki.version.game", wikiSuffix));
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
@@ -449,5 +240,285 @@ public final class VersionsPage extends BorderPane implements WizardPage, Refres
|
||||
content.setExternalLink(null);
|
||||
}
|
||||
}
|
||||
|
||||
private String getWikiUrlSuffix(String gameVersion) {
|
||||
String id = gameVersion.toLowerCase(Locale.ROOT);
|
||||
|
||||
switch (id) {
|
||||
case "0.30-1":
|
||||
case "0.30-2":
|
||||
case "c0.30_01c":
|
||||
return i18n("wiki.version.game.search", "Classic_0.30");
|
||||
case "in-20100206-2103":
|
||||
return i18n("wiki.version.game.search", "Indev_20100206");
|
||||
case "inf-20100630-1":
|
||||
return i18n("wiki.version.game.search", "Infdev_20100630");
|
||||
case "inf-20100630-2":
|
||||
return i18n("wiki.version.game.search", "Alpha_v1.0.0");
|
||||
case "1.19_deep_dark_experimental_snapshot-1":
|
||||
return "1.19-exp1";
|
||||
case "in-20100130":
|
||||
return i18n("wiki.version.game.search", "Indev_0.31_20100130");
|
||||
case "b1.6-tb3":
|
||||
return i18n("wiki.version.game.search", "Beta_1.6_Test_Build_3");
|
||||
case "1.14_combat-212796":
|
||||
return i18n("wiki.version.game.search", "1.14.3_-_Combat_Test");
|
||||
case "1.14_combat-0":
|
||||
return i18n("wiki.version.game.search", "Combat_Test_2");
|
||||
case "1.14_combat-3":
|
||||
return i18n("wiki.version.game.search", "Combat_Test_3");
|
||||
case "1_15_combat-1":
|
||||
return i18n("wiki.version.game.search", "Combat_Test_4");
|
||||
case "1_15_combat-6":
|
||||
return i18n("wiki.version.game.search", "Combat_Test_5");
|
||||
case "1_16_combat-0":
|
||||
return i18n("wiki.version.game.search", "Combat_Test_6");
|
||||
case "1_16_combat-1":
|
||||
return i18n("wiki.version.game.search", "Combat_Test_7");
|
||||
case "1_16_combat-2":
|
||||
return i18n("wiki.version.game.search", "Combat_Test_7b");
|
||||
case "1_16_combat-3":
|
||||
return i18n("wiki.version.game.search", "Combat_Test_7c");
|
||||
case "1_16_combat-4":
|
||||
return i18n("wiki.version.game.search", "Combat_Test_8");
|
||||
case "1_16_combat-5":
|
||||
return i18n("wiki.version.game.search", "Combat_Test_8b");
|
||||
case "1_16_combat-6":
|
||||
return i18n("wiki.version.game.search", "Combat_Test_8c");
|
||||
}
|
||||
|
||||
if (id.startsWith("1.0.0-rc2")) return "RC2";
|
||||
if (id.startsWith("2.0")) return i18n("wiki.version.game.search", "2.0");
|
||||
if (id.startsWith("b1.8-pre1")) return "Beta_1.8-pre1";
|
||||
if (id.startsWith("b1.1-")) return i18n("wiki.version.game.search", "Beta_1.1");
|
||||
if (id.startsWith("a1.1.0")) return "Alpha_v1.1.0";
|
||||
if (id.startsWith("a1.0.14")) return "Alpha_v1.0.14";
|
||||
if (id.startsWith("a1.0.13_01")) return "Alpha_v1.0.13_01";
|
||||
if (id.startsWith("in-20100214")) return i18n("wiki.version.game.search", "Indev_20100214");
|
||||
|
||||
if (id.contains("experimental-snapshot")) {
|
||||
return id.replace("_experimental-snapshot-", "-exp");
|
||||
}
|
||||
|
||||
if (id.startsWith("inf-")) return id.replace("inf-", "Infdev_");
|
||||
if (id.startsWith("in-")) return id.replace("in-", "Indev_");
|
||||
if (id.startsWith("rd-")) return "pre-Classic_" + id;
|
||||
if (id.startsWith("b")) return id.replace("b", "Beta_");
|
||||
if (id.startsWith("a")) return id.replace("a", "Alpha_v");
|
||||
if (id.startsWith("c")) return id.replace("c", "Classic_").replace("st", "SURVIVAL_TEST");
|
||||
|
||||
return i18n("wiki.version.game.search", id);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class VersionsPageSkin extends SkinBase<VersionsPage> {
|
||||
private final JFXListView<RemoteVersion> list;
|
||||
|
||||
private final TransitionPane transitionPane;
|
||||
private final JFXSpinner spinner;
|
||||
|
||||
private final JFXTextField nameField;
|
||||
private final JFXComboBox<VersionTypeFilter> categoryField = new JFXComboBox<>();
|
||||
|
||||
VersionsPageSkin(VersionsPage control) {
|
||||
super(control);
|
||||
|
||||
BorderPane root = new BorderPane();
|
||||
|
||||
GridPane searchPane = new GridPane();
|
||||
root.setTop(searchPane);
|
||||
searchPane.getStyleClass().addAll("card");
|
||||
BorderPane.setMargin(searchPane, new Insets(10, 10, 0, 10));
|
||||
|
||||
ColumnConstraints nameColumn = new ColumnConstraints();
|
||||
nameColumn.setMinWidth(USE_PREF_SIZE);
|
||||
ColumnConstraints column1 = new ColumnConstraints();
|
||||
column1.setHgrow(Priority.ALWAYS);
|
||||
ColumnConstraints column2 = new ColumnConstraints();
|
||||
column2.setMaxWidth(150);
|
||||
ColumnConstraints column3 = new ColumnConstraints();
|
||||
|
||||
if (control.versionList.hasType())
|
||||
searchPane.getColumnConstraints().setAll(nameColumn, column1, nameColumn, column2, column3);
|
||||
else
|
||||
searchPane.getColumnConstraints().setAll(nameColumn, column1, column3);
|
||||
|
||||
searchPane.setHgap(16);
|
||||
searchPane.setVgap(10);
|
||||
|
||||
{
|
||||
int rowIndex = 0;
|
||||
|
||||
{
|
||||
nameField = new JFXTextField();
|
||||
nameField.setPromptText(i18n("version.search.prompt"));
|
||||
nameField.textProperty().addListener(o -> updateList());
|
||||
|
||||
if ("game".equals(control.libraryId)) {
|
||||
categoryField.getItems().setAll(
|
||||
VersionTypeFilter.ALL,
|
||||
VersionTypeFilter.RELEASE,
|
||||
VersionTypeFilter.SNAPSHOTS,
|
||||
VersionTypeFilter.APRIL_FOOLS,
|
||||
VersionTypeFilter.OLD
|
||||
);
|
||||
categoryField.getSelectionModel().select(VersionTypeFilter.RELEASE);
|
||||
} else {
|
||||
categoryField.getItems().setAll(
|
||||
VersionTypeFilter.ALL,
|
||||
VersionTypeFilter.RELEASE,
|
||||
VersionTypeFilter.SNAPSHOTS
|
||||
);
|
||||
categoryField.getSelectionModel().select(VersionTypeFilter.ALL);
|
||||
}
|
||||
categoryField.setConverter(stringConverter(type -> i18n("version.game." + type.name().toLowerCase(Locale.ROOT))));
|
||||
categoryField.getSelectionModel().selectedItemProperty().addListener(o -> updateList());
|
||||
|
||||
JFXButton refreshButton = FXUtils.newRaisedButton(i18n("button.refresh"));
|
||||
refreshButton.setOnAction(event -> control.onRefresh());
|
||||
|
||||
if (control.versionList.hasType()) {
|
||||
searchPane.addRow(rowIndex++,
|
||||
new Label(i18n("version.search")), nameField,
|
||||
new Label(i18n("version.game.type")), categoryField,
|
||||
refreshButton
|
||||
);
|
||||
} else {
|
||||
searchPane.addRow(rowIndex++,
|
||||
new Label(i18n("version.search")), nameField,
|
||||
refreshButton
|
||||
);
|
||||
}
|
||||
}
|
||||
// {
|
||||
// HBox actionsBox = new HBox(8);
|
||||
// GridPane.setColumnSpan(actionsBox, 4);
|
||||
// actionsBox.setAlignment(Pos.CENTER_RIGHT);
|
||||
//
|
||||
// JFXButton refreshButton = FXUtils.newRaisedButton(i18n("button.refresh"));
|
||||
// refreshButton.setOnAction(event -> control.onRefresh());
|
||||
//
|
||||
// actionsBox.getChildren().setAll(refreshButton);
|
||||
//
|
||||
// searchPane.addRow(rowIndex++, actionsBox);
|
||||
// }
|
||||
}
|
||||
|
||||
{
|
||||
SpinnerPane spinnerPane = new SpinnerPane();
|
||||
root.setCenter(spinnerPane);
|
||||
|
||||
transitionPane = new TransitionPane();
|
||||
spinner = new JFXSpinner();
|
||||
|
||||
StackPane centerWrapper = new StackPane();
|
||||
centerWrapper.setStyle("-fx-padding: 10;");
|
||||
{
|
||||
ComponentList centrePane = new ComponentList();
|
||||
centrePane.getStyleClass().add("no-padding");
|
||||
{
|
||||
list = new JFXListView<>();
|
||||
list.getStyleClass().add("jfx-list-view-float");
|
||||
VBox.setVgrow(list, Priority.ALWAYS);
|
||||
|
||||
control.versions.addListener((InvalidationListener) o -> updateList());
|
||||
|
||||
Holder<RemoteVersionListCell> lastCell = new Holder<>();
|
||||
list.setCellFactory(listView -> new RemoteVersionListCell(lastCell, control.libraryId));
|
||||
|
||||
FXUtils.onClicked(list, () -> {
|
||||
if (list.getSelectionModel().getSelectedIndex() < 0)
|
||||
return;
|
||||
control.navigation.getSettings().put(control.libraryId, list.getSelectionModel().getSelectedItem());
|
||||
control.callback.run();
|
||||
});
|
||||
|
||||
ComponentList.setVgrow(list, Priority.ALWAYS);
|
||||
|
||||
// ListViewBehavior would consume ESC pressed event, preventing us from handling it, so we ignore it here
|
||||
ignoreEvent(list, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
|
||||
|
||||
centrePane.getContent().setAll(list);
|
||||
}
|
||||
|
||||
centerWrapper.getChildren().setAll(centrePane);
|
||||
}
|
||||
|
||||
StackPane failedPane = new StackPane();
|
||||
failedPane.getStyleClass().add("notice-pane");
|
||||
{
|
||||
Label label = new Label(i18n("download.failed.refresh"));
|
||||
FXUtils.onClicked(label, control::onRefresh);
|
||||
|
||||
failedPane.getChildren().setAll(label);
|
||||
}
|
||||
|
||||
StackPane emptyPane = new StackPane();
|
||||
emptyPane.getStyleClass().add("notice-pane");
|
||||
{
|
||||
Label label = new Label(i18n("download.failed.empty"));
|
||||
FXUtils.onClicked(label, control::onBack);
|
||||
|
||||
emptyPane.getChildren().setAll(label);
|
||||
}
|
||||
|
||||
FXUtils.onChangeAndOperate(control.status, status -> {
|
||||
if (status == Status.LOADING)
|
||||
transitionPane.setContent(spinner, ContainerAnimations.FADE);
|
||||
else if (status == Status.SUCCESS)
|
||||
transitionPane.setContent(centerWrapper, ContainerAnimations.FADE);
|
||||
else // if (status == Status.FAILED)
|
||||
transitionPane.setContent(failedPane, ContainerAnimations.FADE);
|
||||
});
|
||||
|
||||
root.setCenter(transitionPane);
|
||||
}
|
||||
|
||||
this.getChildren().setAll(root);
|
||||
}
|
||||
|
||||
private void updateList() {
|
||||
Stream<RemoteVersion> versions = getSkinnable().versions.stream();
|
||||
|
||||
VersionTypeFilter filter = categoryField.getSelectionModel().getSelectedItem();
|
||||
if (filter != null)
|
||||
versions = versions.filter(it -> {
|
||||
RemoteVersion.Type versionType = it.getVersionType();
|
||||
switch (filter) {
|
||||
case RELEASE:
|
||||
return versionType == RemoteVersion.Type.RELEASE;
|
||||
case SNAPSHOTS:
|
||||
return versionType == RemoteVersion.Type.SNAPSHOT
|
||||
|| versionType == RemoteVersion.Type.PENDING;
|
||||
case APRIL_FOOLS:
|
||||
return versionType == RemoteVersion.Type.SNAPSHOT
|
||||
&& GameVersionNumber.asGameVersion(it.getGameVersion()).isAprilFools();
|
||||
case OLD:
|
||||
return versionType == RemoteVersion.Type.OLD;
|
||||
case ALL:
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
String nameQuery = nameField.getText();
|
||||
if (!StringUtils.isBlank(nameQuery)) {
|
||||
if (nameQuery.startsWith("regex:")) {
|
||||
try {
|
||||
Pattern pattern = Pattern.compile(nameQuery.substring("regex:".length()));
|
||||
versions = versions.filter(it -> pattern.matcher(it.getSelfVersion()).find());
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Illegal regular expression: " + nameQuery, e);
|
||||
}
|
||||
} else {
|
||||
String lowerQueryString = nameQuery.toLowerCase(Locale.ROOT);
|
||||
versions = versions.filter(it -> it.getSelfVersion().toLowerCase(Locale.ROOT).contains(lowerQueryString));
|
||||
}
|
||||
}
|
||||
|
||||
//noinspection DataFlowIssue
|
||||
list.getItems().setAll(versions.collect(Collectors.toList()));
|
||||
list.scrollTo(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackExportTask;
|
||||
import org.jackhuang.hmcl.mod.multimc.MultiMCInstanceConfiguration;
|
||||
import org.jackhuang.hmcl.mod.multimc.MultiMCModpackExportTask;
|
||||
import org.jackhuang.hmcl.mod.server.ServerModpackExportTask;
|
||||
import org.jackhuang.hmcl.mod.modrinth.ModrinthModpackExportTask;
|
||||
import org.jackhuang.hmcl.setting.Config;
|
||||
import org.jackhuang.hmcl.setting.FontManager;
|
||||
import org.jackhuang.hmcl.setting.Profile;
|
||||
@@ -76,6 +77,10 @@ public final class ExportWizardProvider implements WizardProvider {
|
||||
File tempModpack;
|
||||
Task<?> exportTask;
|
||||
|
||||
{
|
||||
setSignificance(TaskSignificance.MODERATE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean doPreExecute() {
|
||||
return true;
|
||||
@@ -100,6 +105,9 @@ public final class ExportWizardProvider implements WizardProvider {
|
||||
case ModpackTypeSelectionPage.MODPACK_TYPE_SERVER:
|
||||
exportTask = exportAsServer(exportInfo, dest);
|
||||
break;
|
||||
case ModpackTypeSelectionPage.MODPACK_TYPE_MODRINTH:
|
||||
exportTask = exportAsModrinth(exportInfo, dest);
|
||||
break;
|
||||
default:
|
||||
throw new IllegalStateException("Unrecognized modpack type " + modpackType);
|
||||
}
|
||||
@@ -161,6 +169,10 @@ public final class ExportWizardProvider implements WizardProvider {
|
||||
return new Task<Void>() {
|
||||
Task<?> dependency = null;
|
||||
|
||||
{
|
||||
setSignificance(TaskSignificance.MODERATE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute() {
|
||||
dependency = new McbbsModpackExportTask(profile.getRepository(), version, exportInfo, modpackFile);
|
||||
@@ -177,6 +189,10 @@ public final class ExportWizardProvider implements WizardProvider {
|
||||
return new Task<Void>() {
|
||||
Task<?> dependency;
|
||||
|
||||
{
|
||||
setSignificance(TaskSignificance.MODERATE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute() {
|
||||
VersionSetting vs = profile.getVersionSetting(version);
|
||||
@@ -221,6 +237,10 @@ public final class ExportWizardProvider implements WizardProvider {
|
||||
return new Task<Void>() {
|
||||
Task<?> dependency;
|
||||
|
||||
{
|
||||
setSignificance(TaskSignificance.MODERATE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute() {
|
||||
dependency = new ServerModpackExportTask(profile.getRepository(), version, exportInfo, modpackFile);
|
||||
@@ -233,6 +253,31 @@ public final class ExportWizardProvider implements WizardProvider {
|
||||
};
|
||||
}
|
||||
|
||||
private Task<?> exportAsModrinth(ModpackExportInfo exportInfo, File modpackFile) {
|
||||
return new Task<Void>() {
|
||||
Task<?> dependency;
|
||||
|
||||
{
|
||||
setSignificance(TaskSignificance.MODERATE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute() {
|
||||
dependency = new ModrinthModpackExportTask(
|
||||
profile.getRepository(),
|
||||
version,
|
||||
exportInfo,
|
||||
modpackFile
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<Task<?>> getDependencies() {
|
||||
return Collections.singleton(dependency);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public Node createPage(WizardController controller, int step, Map<String, Object> settings) {
|
||||
switch (step) {
|
||||
|
||||
@@ -53,6 +53,7 @@ import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.jfxListCellFactory;
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.stringConverter;
|
||||
import static org.jackhuang.hmcl.ui.export.ModpackTypeSelectionPage.MODPACK_TYPE;
|
||||
import static org.jackhuang.hmcl.ui.export.ModpackTypeSelectionPage.MODPACK_TYPE_MODRINTH;
|
||||
import static org.jackhuang.hmcl.ui.export.ModpackTypeSelectionPage.MODPACK_TYPE_SERVER;
|
||||
import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES;
|
||||
import static org.jackhuang.hmcl.util.Lang.tryCast;
|
||||
@@ -80,6 +81,8 @@ public final class ModpackInfoPage extends Control implements WizardPage {
|
||||
private final SimpleStringProperty launchArguments = new SimpleStringProperty("");
|
||||
private final SimpleStringProperty javaArguments = new SimpleStringProperty("");
|
||||
private final SimpleStringProperty mcbbsThreadId = new SimpleStringProperty("");
|
||||
private final SimpleBooleanProperty noCreateRemoteFiles = new SimpleBooleanProperty();
|
||||
private final SimpleBooleanProperty skipCurseForgeRemoteFiles = new SimpleBooleanProperty();
|
||||
|
||||
public ModpackInfoPage(WizardController controller, HMCLGameRepository gameRepository, String version) {
|
||||
this.controller = controller;
|
||||
@@ -102,7 +105,13 @@ public final class ModpackInfoPage extends Control implements WizardPage {
|
||||
private void onNext() {
|
||||
FileChooser fileChooser = new FileChooser();
|
||||
fileChooser.setTitle(i18n("modpack.wizard.step.initialization.save"));
|
||||
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("modpack"), "*.zip"));
|
||||
if (controller.getSettings().get(MODPACK_TYPE) == ModpackTypeSelectionPage.MODPACK_TYPE_MODRINTH) {
|
||||
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("modpack"), "*.mrpack"));
|
||||
fileChooser.setInitialFileName(name.get() + ".mrpack");
|
||||
} else {
|
||||
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("modpack"), "*.zip"));
|
||||
fileChooser.setInitialFileName(name.get() + ".zip");
|
||||
}
|
||||
File file = fileChooser.showSaveDialog(Controllers.getStage());
|
||||
if (file == null) {
|
||||
controller.onEnd();
|
||||
@@ -122,6 +131,8 @@ public final class ModpackInfoPage extends Control implements WizardPage {
|
||||
exportInfo.setLaunchArguments(launchArguments.get());
|
||||
exportInfo.setJavaArguments(javaArguments.get());
|
||||
exportInfo.setAuthlibInjectorServer(authlibInjectorServer.get());
|
||||
exportInfo.setNoCreateRemoteFiles(noCreateRemoteFiles.get());
|
||||
exportInfo.setSkipCurseForgeRemoteFiles(skipCurseForgeRemoteFiles.get());
|
||||
|
||||
if (StringUtils.isNotBlank(mcbbsThreadId.get())) {
|
||||
exportInfo.setOrigins(Collections.singletonList(new McbbsModpackManifest.Origin(
|
||||
@@ -178,6 +189,10 @@ public final class ModpackInfoPage extends Control implements WizardPage {
|
||||
Hyperlink hyperlink = new Hyperlink(i18n("modpack.wizard.step.initialization.server"));
|
||||
hyperlink.setOnAction(e -> FXUtils.openLink(Metadata.DOCS_URL + "/modpack/serverpack.html"));
|
||||
borderPane.setTop(hyperlink);
|
||||
} if (skinnable.controller.getSettings().get(MODPACK_TYPE) == MODPACK_TYPE_MODRINTH) {
|
||||
HintPane pane = new HintPane(MessageDialogPane.MessageType.INFO);
|
||||
pane.setText(i18n("modpack.wizard.step.initialization.modrinth.info"));
|
||||
borderPane.setTop(pane);
|
||||
} else {
|
||||
HintPane pane = new HintPane(MessageDialogPane.MessageType.INFO);
|
||||
pane.setText(i18n("modpack.wizard.step.initialization.warning"));
|
||||
@@ -366,6 +381,32 @@ public final class ModpackInfoPage extends Control implements WizardPage {
|
||||
button.setMaxHeight(16);
|
||||
pane.setRight(button);
|
||||
}
|
||||
|
||||
if (skinnable.options.isRequireNoCreateRemoteFiles()) {
|
||||
BorderPane noCreateRemoteFiles = new BorderPane();
|
||||
noCreateRemoteFiles.setLeft(new Label(i18n("modpack.wizard.step.initialization.no_create_remote_files")));
|
||||
list.getContent().add(noCreateRemoteFiles);
|
||||
|
||||
JFXToggleButton noCreateRemoteFilesButton = new JFXToggleButton();
|
||||
noCreateRemoteFilesButton.selectedProperty().bindBidirectional(skinnable.noCreateRemoteFiles);
|
||||
noCreateRemoteFilesButton.setSize(8);
|
||||
noCreateRemoteFilesButton.setMinHeight(16);
|
||||
noCreateRemoteFilesButton.setMaxHeight(16);
|
||||
noCreateRemoteFiles.setRight(noCreateRemoteFilesButton);
|
||||
}
|
||||
|
||||
if (skinnable.options.isRequireSkipCurseForgeRemoteFiles()) {
|
||||
BorderPane skipCurseForgeRemoteFiles = new BorderPane();
|
||||
skipCurseForgeRemoteFiles.setLeft(new Label(i18n("modpack.wizard.step.initialization.skip_curseforge_remote_files")));
|
||||
list.getContent().add(skipCurseForgeRemoteFiles);
|
||||
|
||||
JFXToggleButton skipCurseForgeRemoteFilesButton = new JFXToggleButton();
|
||||
skipCurseForgeRemoteFilesButton.selectedProperty().bindBidirectional(skinnable.skipCurseForgeRemoteFiles);
|
||||
skipCurseForgeRemoteFilesButton.setSize(8);
|
||||
skipCurseForgeRemoteFilesButton.setMinHeight(16);
|
||||
skipCurseForgeRemoteFilesButton.setMaxHeight(16);
|
||||
skipCurseForgeRemoteFiles.setRight(skipCurseForgeRemoteFilesButton);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
|
||||
@@ -28,6 +28,7 @@ import org.jackhuang.hmcl.mod.ModpackExportInfo;
|
||||
import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackExportTask;
|
||||
import org.jackhuang.hmcl.mod.multimc.MultiMCModpackExportTask;
|
||||
import org.jackhuang.hmcl.mod.server.ServerModpackExportTask;
|
||||
import org.jackhuang.hmcl.mod.modrinth.ModrinthModpackExportTask;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
|
||||
import org.jackhuang.hmcl.ui.wizard.WizardController;
|
||||
@@ -56,7 +57,8 @@ public final class ModpackTypeSelectionPage extends VBox implements WizardPage {
|
||||
title,
|
||||
createButton(MODPACK_TYPE_MCBBS, McbbsModpackExportTask.OPTION),
|
||||
createButton(MODPACK_TYPE_MULTIMC, MultiMCModpackExportTask.OPTION),
|
||||
createButton(MODPACK_TYPE_SERVER, ServerModpackExportTask.OPTION)
|
||||
createButton(MODPACK_TYPE_SERVER, ServerModpackExportTask.OPTION),
|
||||
createButton(MODPACK_TYPE_MODRINTH, ModrinthModpackExportTask.OPTION)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -100,4 +102,5 @@ public final class ModpackTypeSelectionPage extends VBox implements WizardPage {
|
||||
public static final String MODPACK_TYPE_MCBBS = "mcbbs";
|
||||
public static final String MODPACK_TYPE_MULTIMC = "multimc";
|
||||
public static final String MODPACK_TYPE_SERVER = "server";
|
||||
public static final String MODPACK_TYPE_MODRINTH = "modrinth";
|
||||
}
|
||||
+2
-9
@@ -15,17 +15,10 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.util;
|
||||
package org.jackhuang.hmcl.ui.image;
|
||||
|
||||
/**
|
||||
* @author Glavo
|
||||
*/
|
||||
public final class ModuleHelper {
|
||||
|
||||
public static void addEnableNativeAccess(Class<?> clazzInModule) {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
private ModuleHelper() {
|
||||
}
|
||||
public interface AnimationImage {
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.ui.image;
|
||||
|
||||
import javafx.scene.image.Image;
|
||||
|
||||
import java.io.InputStream;
|
||||
|
||||
public interface ImageLoader {
|
||||
Image load(InputStream input,
|
||||
int requestedWidth, int requestedHeight,
|
||||
boolean preserveRatio, boolean smooth) throws Exception;
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.ui.image;
|
||||
|
||||
import com.twelvemonkeys.imageio.plugins.webp.WebPImageReaderSpi;
|
||||
import javafx.animation.Timeline;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.image.PixelFormat;
|
||||
import javafx.scene.image.WritableImage;
|
||||
import org.jackhuang.hmcl.ui.image.apng.Png;
|
||||
import org.jackhuang.hmcl.ui.image.apng.argb8888.Argb8888Bitmap;
|
||||
import org.jackhuang.hmcl.ui.image.apng.argb8888.Argb8888BitmapSequence;
|
||||
import org.jackhuang.hmcl.ui.image.apng.chunks.PngAnimationControl;
|
||||
import org.jackhuang.hmcl.ui.image.apng.chunks.PngFrameControl;
|
||||
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
|
||||
import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
|
||||
import org.jackhuang.hmcl.ui.image.internal.AnimationImageImpl;
|
||||
import org.jackhuang.hmcl.util.SwingFXUtils;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.imageio.ImageReader;
|
||||
import javax.imageio.stream.ImageInputStream;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.*;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/**
|
||||
* @author Glavo
|
||||
*/
|
||||
public final class ImageUtils {
|
||||
|
||||
// ImageLoaders
|
||||
|
||||
public static final ImageLoader DEFAULT = (input, requestedWidth, requestedHeight, preserveRatio, smooth) -> {
|
||||
Image image = new Image(input,
|
||||
requestedWidth, requestedHeight,
|
||||
preserveRatio, smooth);
|
||||
if (image.isError())
|
||||
throw image.getException();
|
||||
return image;
|
||||
};
|
||||
|
||||
public static final ImageLoader WEBP = (input, requestedWidth, requestedHeight, preserveRatio, smooth) -> {
|
||||
WebPImageReaderSpi spi = new WebPImageReaderSpi();
|
||||
ImageReader reader = spi.createReaderInstance(null);
|
||||
BufferedImage bufferedImage;
|
||||
try (ImageInputStream imageInput = ImageIO.createImageInputStream(input)) {
|
||||
reader.setInput(imageInput, true, true);
|
||||
bufferedImage = reader.read(0, reader.getDefaultReadParam());
|
||||
} finally {
|
||||
reader.dispose();
|
||||
}
|
||||
return SwingFXUtils.toFXImage(bufferedImage, requestedWidth, requestedHeight, preserveRatio, smooth);
|
||||
};
|
||||
|
||||
public static final ImageLoader APNG = (input, requestedWidth, requestedHeight, preserveRatio, smooth) -> {
|
||||
if (!"true".equals(System.getProperty("hmcl.experimental.apng", "true")))
|
||||
return DEFAULT.load(input, requestedWidth, requestedHeight, preserveRatio, smooth);
|
||||
|
||||
try {
|
||||
var sequence = Png.readArgb8888BitmapSequence(input);
|
||||
|
||||
final int width = sequence.header.width;
|
||||
final int height = sequence.header.height;
|
||||
|
||||
boolean doScale;
|
||||
if (requestedWidth > 0 && requestedHeight > 0
|
||||
&& (requestedWidth != width || requestedHeight != height)) {
|
||||
doScale = true;
|
||||
|
||||
if (preserveRatio) {
|
||||
double scaleX = (double) requestedWidth / width;
|
||||
double scaleY = (double) requestedHeight / height;
|
||||
double scale = Math.min(scaleX, scaleY);
|
||||
|
||||
requestedWidth = (int) (width * scale);
|
||||
requestedHeight = (int) (height * scale);
|
||||
}
|
||||
} else {
|
||||
doScale = false;
|
||||
}
|
||||
|
||||
if (sequence.isAnimated()) {
|
||||
try {
|
||||
return toImage(sequence, doScale, requestedWidth, requestedHeight);
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to load animated image", e);
|
||||
}
|
||||
}
|
||||
|
||||
Argb8888Bitmap defaultImage = sequence.defaultImage;
|
||||
int targetWidth;
|
||||
int targetHeight;
|
||||
int[] pixels;
|
||||
if (doScale) {
|
||||
targetWidth = requestedWidth;
|
||||
targetHeight = requestedHeight;
|
||||
pixels = scale(defaultImage.array,
|
||||
defaultImage.width, defaultImage.height,
|
||||
targetWidth, targetHeight);
|
||||
} else {
|
||||
targetWidth = defaultImage.width;
|
||||
targetHeight = defaultImage.height;
|
||||
pixels = defaultImage.array;
|
||||
}
|
||||
|
||||
WritableImage image = new WritableImage(targetWidth, targetHeight);
|
||||
image.getPixelWriter().setPixels(0, 0, targetWidth, targetHeight,
|
||||
PixelFormat.getIntArgbInstance(), pixels,
|
||||
0, targetWidth);
|
||||
return image;
|
||||
} catch (PngException e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
};
|
||||
|
||||
public static final Map<String, ImageLoader> EXT_TO_LOADER = Map.of(
|
||||
"webp", WEBP,
|
||||
"apng", APNG
|
||||
);
|
||||
|
||||
public static final Map<String, ImageLoader> CONTENT_TYPE_TO_LOADER = Map.of(
|
||||
"image/webp", WEBP,
|
||||
"image/apng", APNG
|
||||
);
|
||||
|
||||
public static final Set<String> DEFAULT_EXTS = Set.of(
|
||||
"jpg", "jpeg", "bmp", "gif"
|
||||
);
|
||||
|
||||
public static final Set<String> DEFAULT_CONTENT_TYPES = Set.of(
|
||||
"image/jpeg", "image/bmp", "image/gif"
|
||||
);
|
||||
|
||||
// ------
|
||||
|
||||
public static final int HEADER_BUFFER_SIZE = 1024;
|
||||
|
||||
private static final byte[] RIFF_HEADER = {'R', 'I', 'F', 'F'};
|
||||
private static final byte[] WEBP_HEADER = {'W', 'E', 'B', 'P'};
|
||||
|
||||
public static boolean isWebP(byte[] headerBuffer) {
|
||||
return headerBuffer.length > 12
|
||||
&& Arrays.equals(headerBuffer, 0, 4, RIFF_HEADER, 0, 4)
|
||||
&& Arrays.equals(headerBuffer, 8, 12, WEBP_HEADER, 0, 4);
|
||||
}
|
||||
|
||||
private static final byte[] PNG_HEADER = {
|
||||
(byte) 0x89, (byte) 0x50, (byte) 0x4e, (byte) 0x47,
|
||||
(byte) 0x0d, (byte) 0x0a, (byte) 0x1a, (byte) 0x0a,
|
||||
};
|
||||
|
||||
private static final class PngChunkHeader {
|
||||
private static final int IDAT_HEADER = 0x49444154;
|
||||
private static final int acTL_HEADER = 0x6163544c;
|
||||
|
||||
private final int length;
|
||||
private final int chunkType;
|
||||
|
||||
private PngChunkHeader(int length, int chunkType) {
|
||||
this.length = length;
|
||||
this.chunkType = chunkType;
|
||||
}
|
||||
|
||||
private static @Nullable PngChunkHeader readHeader(ByteBuffer headerBuffer) {
|
||||
if (headerBuffer.remaining() < 8)
|
||||
return null;
|
||||
|
||||
int length = headerBuffer.getInt();
|
||||
int chunkType = headerBuffer.getInt();
|
||||
|
||||
return new PngChunkHeader(length, chunkType);
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isApng(byte[] headerBuffer) {
|
||||
if (headerBuffer.length <= 20)
|
||||
return false;
|
||||
|
||||
if (!Arrays.equals(
|
||||
headerBuffer, 0, 8,
|
||||
PNG_HEADER, 0, 8))
|
||||
return false;
|
||||
|
||||
|
||||
ByteBuffer buffer = ByteBuffer.wrap(headerBuffer, 8, headerBuffer.length - 8);
|
||||
|
||||
PngChunkHeader header;
|
||||
while ((header = PngChunkHeader.readHeader(buffer)) != null) {
|
||||
// https://wiki.mozilla.org/APNG_Specification#Structure
|
||||
// To be recognized as an APNG, an `acTL` chunk must appear in the stream before any `IDAT` chunks.
|
||||
// The `acTL` structure is described below.
|
||||
if (header.chunkType == PngChunkHeader.IDAT_HEADER)
|
||||
break;
|
||||
|
||||
if (header.chunkType == PngChunkHeader.acTL_HEADER)
|
||||
return true;
|
||||
|
||||
final int numBytes = header.length + 4;
|
||||
|
||||
if (buffer.remaining() > numBytes)
|
||||
buffer.position(buffer.position() + numBytes);
|
||||
else
|
||||
break;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static @Nullable ImageLoader guessLoader(byte[] headerBuffer) {
|
||||
if (isWebP(headerBuffer))
|
||||
return WEBP;
|
||||
if (isApng(headerBuffer))
|
||||
return APNG;
|
||||
return null;
|
||||
}
|
||||
|
||||
public static final Pattern CONTENT_TYPE_PATTERN = Pattern.compile("^\\s(?<type>image/[\\w-])");
|
||||
|
||||
// APNG
|
||||
|
||||
private static int[] scale(int[] pixels,
|
||||
int sourceWidth, int sourceHeight,
|
||||
int targetWidth, int targetHeight) {
|
||||
assert pixels.length == sourceWidth * sourceHeight;
|
||||
|
||||
double xScale = ((double) sourceWidth) / targetWidth;
|
||||
double yScale = ((double) sourceHeight) / targetHeight;
|
||||
|
||||
int[] result = new int[targetWidth * targetHeight];
|
||||
|
||||
for (int row = 0; row < targetHeight; row++) {
|
||||
for (int col = 0; col < targetWidth; col++) {
|
||||
int sourceX = (int) (col * xScale);
|
||||
int sourceY = (int) (row * yScale);
|
||||
int color = pixels[sourceY * sourceWidth + sourceX];
|
||||
|
||||
result[row * targetWidth + col] = color;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Image toImage(Argb8888BitmapSequence sequence,
|
||||
boolean doScale,
|
||||
int targetWidth, int targetHeight) throws PngException {
|
||||
final int width = sequence.header.width;
|
||||
final int height = sequence.header.height;
|
||||
|
||||
List<Argb8888BitmapSequence.Frame> frames = sequence.getAnimationFrames();
|
||||
|
||||
var framePixels = new int[frames.size()][];
|
||||
var durations = new int[framePixels.length];
|
||||
|
||||
int[] buffer = new int[Math.multiplyExact(width, height)];
|
||||
for (int frameIndex = 0; frameIndex < frames.size(); frameIndex++) {
|
||||
var frame = frames.get(frameIndex);
|
||||
PngFrameControl control = frame.control;
|
||||
|
||||
if (frameIndex == 0 && (
|
||||
control.xOffset != 0 || control.yOffset != 0
|
||||
|| control.width != width || control.height != height)) {
|
||||
throw new PngIntegrityException("Invalid first frame: " + control);
|
||||
}
|
||||
|
||||
if (control.xOffset < 0 || control.yOffset < 0
|
||||
|| width < 0 || height < 0
|
||||
|| control.xOffset + control.width > width
|
||||
|| control.yOffset + control.height > height
|
||||
|| control.delayNumerator < 0 || control.delayDenominator < 0
|
||||
) {
|
||||
throw new PngIntegrityException("Invalid frame control: " + control);
|
||||
}
|
||||
|
||||
int[] currentFrameBuffer = buffer.clone();
|
||||
if (control.blendOp == 0) {
|
||||
for (int row = 0; row < control.height; row++) {
|
||||
System.arraycopy(frame.bitmap.array,
|
||||
row * control.width,
|
||||
currentFrameBuffer,
|
||||
(control.yOffset + row) * width + control.xOffset,
|
||||
control.width);
|
||||
}
|
||||
} else if (control.blendOp == 1) {
|
||||
// APNG_BLEND_OP_OVER - Alpha blending
|
||||
for (int row = 0; row < control.height; row++) {
|
||||
for (int col = 0; col < control.width; col++) {
|
||||
int srcIndex = row * control.width + col;
|
||||
int dstIndex = (control.yOffset + row) * width + control.xOffset + col;
|
||||
|
||||
int srcPixel = frame.bitmap.array[srcIndex];
|
||||
int dstPixel = currentFrameBuffer[dstIndex];
|
||||
|
||||
int srcAlpha = (srcPixel >>> 24) & 0xFF;
|
||||
if (srcAlpha == 0) {
|
||||
continue;
|
||||
} else if (srcAlpha == 255) {
|
||||
currentFrameBuffer[dstIndex] = srcPixel;
|
||||
} else {
|
||||
int srcR = (srcPixel >>> 16) & 0xFF;
|
||||
int srcG = (srcPixel >>> 8) & 0xFF;
|
||||
int srcB = srcPixel & 0xFF;
|
||||
|
||||
int dstAlpha = (dstPixel >>> 24) & 0xFF;
|
||||
int dstR = (dstPixel >>> 16) & 0xFF;
|
||||
int dstG = (dstPixel >>> 8) & 0xFF;
|
||||
int dstB = dstPixel & 0xFF;
|
||||
|
||||
int invSrcAlpha = 255 - srcAlpha;
|
||||
|
||||
int outAlpha = srcAlpha + (dstAlpha * invSrcAlpha + 127) / 255;
|
||||
int outR, outG, outB;
|
||||
|
||||
if (outAlpha == 0) {
|
||||
outR = outG = outB = 0;
|
||||
} else {
|
||||
outR = (srcR * srcAlpha + dstR * dstAlpha * invSrcAlpha / 255 + outAlpha / 2) / outAlpha;
|
||||
outG = (srcG * srcAlpha + dstG * dstAlpha * invSrcAlpha / 255 + outAlpha / 2) / outAlpha;
|
||||
outB = (srcB * srcAlpha + dstB * dstAlpha * invSrcAlpha / 255 + outAlpha / 2) / outAlpha;
|
||||
}
|
||||
|
||||
outAlpha = Math.min(outAlpha, 255);
|
||||
outR = Math.min(outR, 255);
|
||||
outG = Math.min(outG, 255);
|
||||
outB = Math.min(outB, 255);
|
||||
|
||||
currentFrameBuffer[dstIndex] = (outAlpha << 24) | (outR << 16) | (outG << 8) | outB;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new PngIntegrityException("Unsupported blendOp " + control.blendOp + " at frame " + frameIndex);
|
||||
}
|
||||
|
||||
if (doScale)
|
||||
framePixels[frameIndex] = scale(currentFrameBuffer,
|
||||
width, height,
|
||||
targetWidth, targetHeight);
|
||||
else
|
||||
framePixels[frameIndex] = currentFrameBuffer;
|
||||
|
||||
if (control.delayNumerator == 0) {
|
||||
durations[frameIndex] = 10;
|
||||
} else {
|
||||
int durationsMills = 1000 * control.delayNumerator;
|
||||
if (control.delayDenominator == 0)
|
||||
durationsMills /= 100;
|
||||
else
|
||||
durationsMills /= control.delayDenominator;
|
||||
|
||||
durations[frameIndex] = durationsMills;
|
||||
}
|
||||
|
||||
switch (control.disposeOp) {
|
||||
case 0: // APNG_DISPOST_OP_NONE
|
||||
System.arraycopy(currentFrameBuffer, 0, buffer, 0, currentFrameBuffer.length);
|
||||
break;
|
||||
case 1: // APNG_DISPOSE_OP_BACKGROUND
|
||||
for (int row = 0; row < control.height; row++) {
|
||||
int fromIndex = (control.yOffset + row) * width + control.xOffset;
|
||||
Arrays.fill(buffer, fromIndex, fromIndex + control.width, 0);
|
||||
}
|
||||
break;
|
||||
case 2: // APNG_DISPOSE_OP_PREVIOUS
|
||||
// Do nothing, keep the previous frame.
|
||||
break;
|
||||
default:
|
||||
throw new PngIntegrityException("Unsupported disposeOp " + control.disposeOp + " at frame " + frameIndex);
|
||||
}
|
||||
}
|
||||
|
||||
PngAnimationControl animationControl = sequence.getAnimationControl();
|
||||
int cycleCount;
|
||||
if (animationControl != null) {
|
||||
cycleCount = animationControl.numPlays;
|
||||
if (cycleCount == 0)
|
||||
cycleCount = Timeline.INDEFINITE;
|
||||
} else {
|
||||
cycleCount = Timeline.INDEFINITE;
|
||||
}
|
||||
|
||||
if (doScale)
|
||||
return new AnimationImageImpl(targetWidth, targetHeight, framePixels, durations, cycleCount);
|
||||
else
|
||||
return new AnimationImageImpl(width, height, framePixels, durations, cycleCount);
|
||||
}
|
||||
|
||||
private ImageUtils() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copy from https://github.com/aellerton/japng
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package org.jackhuang.hmcl.ui.image.apng;
|
||||
|
||||
import org.jackhuang.hmcl.ui.image.apng.argb8888.*;
|
||||
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
|
||||
import org.jackhuang.hmcl.ui.image.apng.map.PngMap;
|
||||
import org.jackhuang.hmcl.ui.image.apng.map.PngMapReader;
|
||||
import org.jackhuang.hmcl.ui.image.apng.reader.DefaultPngChunkReader;
|
||||
import org.jackhuang.hmcl.ui.image.apng.reader.PngChunkProcessor;
|
||||
import org.jackhuang.hmcl.ui.image.apng.reader.PngReadHelper;
|
||||
import org.jackhuang.hmcl.ui.image.apng.reader.PngReader;
|
||||
import org.jackhuang.hmcl.ui.image.apng.util.PngContainer;
|
||||
import org.jackhuang.hmcl.ui.image.apng.util.PngContainerBuilder;
|
||||
|
||||
import java.io.InputStream;
|
||||
|
||||
/**
|
||||
* Convenient one liners for loading PNG images.
|
||||
*/
|
||||
public class Png {
|
||||
|
||||
/**
|
||||
* Read the provided stream and produce a PngMap of the data.
|
||||
*
|
||||
* @param is stream to read from
|
||||
* @param sourceName optional name, mainly for debugging.
|
||||
* @return PngMap of the data.
|
||||
* @throws PngException
|
||||
*/
|
||||
public static PngMap readMap(InputStream is, String sourceName) throws PngException {
|
||||
return PngReadHelper.read(is, new PngMapReader(sourceName));
|
||||
}
|
||||
|
||||
public static PngContainer readContainer(InputStream is) throws PngException {
|
||||
return PngReadHelper.read(is, new DefaultPngChunkReader<>(new PngContainerBuilder()));
|
||||
}
|
||||
|
||||
public static <ResultT> ResultT read(InputStream is, PngReader<ResultT> reader) throws PngException {
|
||||
return PngReadHelper.read(is, reader);
|
||||
}
|
||||
|
||||
public static <ResultT> ResultT read(InputStream is, PngChunkProcessor<ResultT> processor) throws PngException {
|
||||
return PngReadHelper.read(is, new DefaultPngChunkReader<>(processor));
|
||||
}
|
||||
|
||||
public static Argb8888Bitmap readArgb8888Bitmap(InputStream is) throws PngException {
|
||||
Argb8888Processor<Argb8888Bitmap> processor = new Argb8888Processor<>(new DefaultImageArgb8888Director());
|
||||
return PngReadHelper.read(is, new DefaultPngChunkReader<>(processor));
|
||||
}
|
||||
|
||||
public static Argb8888BitmapSequence readArgb8888BitmapSequence(InputStream is) throws PngException {
|
||||
Argb8888Processor<Argb8888BitmapSequence> processor = new Argb8888Processor<>(new Argb8888BitmapSequenceDirector());
|
||||
return PngReadHelper.read(is, new DefaultPngChunkReader<>(processor));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Copy from https://github.com/aellerton/japng
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package org.jackhuang.hmcl.ui.image.apng;
|
||||
|
||||
/**
|
||||
* A PNG file has an animation type - most commonly, not animated.
|
||||
* <p>
|
||||
* This is primarily relevant to tracking whether the default image in a PNG
|
||||
* is to be
|
||||
* a) processed as normal (e.g. in a standard or "non-animated" PNG file);
|
||||
* b) processed and used as the first frame in an animated PNG; or
|
||||
* c) not processed and discarded (e.g. in an animated PNG which does not use the first frame).
|
||||
*/
|
||||
public enum PngAnimationType {
|
||||
NOT_ANIMATED,
|
||||
ANIMATED_KEEP_DEFAULT_IMAGE,
|
||||
ANIMATED_DISCARD_DEFAULT_IMAGE;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// Copy from https://github.com/aellerton/japng
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package org.jackhuang.hmcl.ui.image.apng;
|
||||
|
||||
/**
|
||||
* One four-letter (32-byte) code identifying the type of a PNG chunk.
|
||||
* <p>
|
||||
* Common chunk codes are defined as constants that can be used in a switch statement.
|
||||
* Users can add their own chunk codes separately.
|
||||
*/
|
||||
public class PngChunkCode {
|
||||
public static final PngChunkCode IHDR = new PngChunkCode(PngConstants.IHDR_VALUE, "IHDR");
|
||||
public static final PngChunkCode PLTE = new PngChunkCode(PngConstants.PLTE_VALUE, "PLTE");
|
||||
public static final PngChunkCode IDAT = new PngChunkCode(PngConstants.IDAT_VALUE, "IDAT");
|
||||
public static final PngChunkCode IEND = new PngChunkCode(PngConstants.IEND_VALUE, "IEND");
|
||||
public static final PngChunkCode gAMA = new PngChunkCode(PngConstants.gAMA_VALUE, "gAMA");
|
||||
public static final PngChunkCode bKGD = new PngChunkCode(PngConstants.bKGD_VALUE, "bKGD");
|
||||
public static final PngChunkCode tRNS = new PngChunkCode(PngConstants.tRNS_VALUE, "tRNS");
|
||||
public static final PngChunkCode acTL = new PngChunkCode(PngConstants.acTL_VALUE, "acTL");
|
||||
public static final PngChunkCode fcTL = new PngChunkCode(PngConstants.fcTL_VALUE, "fcTL");
|
||||
public static final PngChunkCode fdAT = new PngChunkCode(PngConstants.fdAT_VALUE, "fdAT");
|
||||
|
||||
public final int numeric;
|
||||
public final String letters;
|
||||
|
||||
PngChunkCode(int numeric, String letters) {
|
||||
this.numeric = numeric;
|
||||
this.letters = letters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find out if the chunk is "critical" as defined by http://www.w3.org/TR/PNG/#5Chunk-naming-conventions
|
||||
*
|
||||
* <pre>
|
||||
* cHNk <-- 32 bit chunk type represented in text form
|
||||
* ||||
|
||||
* |||+- Safe-to-copy bit is 1 (lower case letter; bit 5 is 1)
|
||||
* ||+-- Reserved bit is 0 (upper case letter; bit 5 is 0)
|
||||
* |+--- Private bit is 0 (upper case letter; bit 5 is 0)
|
||||
* +---- Ancillary bit is 1 (lower case letter; bit 5 is 1)
|
||||
* </pre>
|
||||
*
|
||||
* @return true if this chunk is considered critical according to the PNG spec.
|
||||
*/
|
||||
public boolean isCritical() {
|
||||
// Ancillary bit: bit 5 o first byte
|
||||
return (numeric & PngConstants.BIT_CHUNK_IS_ANCILLARY) == 0;
|
||||
}
|
||||
|
||||
public boolean isAncillary() {
|
||||
// Ancillary bit: bit 5 of first byte
|
||||
return (numeric & PngConstants.BIT_CHUNK_IS_ANCILLARY) > 0;
|
||||
}
|
||||
|
||||
public boolean isPrivate() {
|
||||
return (numeric & PngConstants.BIT_CHUNK_IS_PRIVATE) > 0;
|
||||
}
|
||||
|
||||
public boolean isPublic() {
|
||||
return (numeric & PngConstants.BIT_CHUNK_IS_PRIVATE) == 0;
|
||||
}
|
||||
|
||||
public boolean isReserved() {
|
||||
return (numeric & PngConstants.BIT_CHUNK_IS_RESERVED) > 0;
|
||||
}
|
||||
|
||||
public boolean isSafeToCopy() {
|
||||
return (numeric & PngConstants.BIT_CHUNK_IS_SAFE_TO_COPY) > 0;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
|
||||
PngChunkCode that = (PngChunkCode) o;
|
||||
|
||||
if (numeric != that.numeric) return false;
|
||||
return !(letters != null ? !letters.equals(that.letters) : that.letters != null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int result = numeric;
|
||||
result = 31 * result + (letters != null ? letters.hashCode() : 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return letters + "(" + numeric + ")";
|
||||
}
|
||||
|
||||
public static PngChunkCode from(int code) {
|
||||
switch (code) {
|
||||
case PngConstants.IHDR_VALUE:
|
||||
return IHDR;
|
||||
case PngConstants.gAMA_VALUE:
|
||||
return gAMA;
|
||||
case PngConstants.bKGD_VALUE:
|
||||
return bKGD;
|
||||
case PngConstants.tRNS_VALUE:
|
||||
return tRNS;
|
||||
case PngConstants.IDAT_VALUE:
|
||||
return IDAT;
|
||||
case PngConstants.IEND_VALUE:
|
||||
return IEND;
|
||||
case PngConstants.acTL_VALUE:
|
||||
return acTL;
|
||||
case PngConstants.fdAT_VALUE:
|
||||
return fdAT;
|
||||
default:
|
||||
byte[] s = new byte[4];
|
||||
s[0] = (byte) ((code & 0xff000000) >> 24);
|
||||
s[1] = (byte) ((code & 0x00ff0000) >> 16);
|
||||
s[2] = (byte) ((code & 0x0000ff00) >> 8);
|
||||
s[3] = (byte) ((code & 0x000000ff));
|
||||
return new PngChunkCode(code, new String(s));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copy from https://github.com/aellerton/japng
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package org.jackhuang.hmcl.ui.image.apng;
|
||||
|
||||
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
|
||||
import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
|
||||
|
||||
/**
|
||||
* PNG images support 5 colour types as defined at http://www.w3.org/TR/PNG/#11IHDR
|
||||
*/
|
||||
public enum PngColourType {
|
||||
PNG_GREYSCALE(0, 1, "1, 2, 4, 8, 16", "Greyscale", "Each pixel is a greyscale sample"),
|
||||
PNG_TRUECOLOUR(2, 3, "8, 16", "Truecolour", "Each pixel is an R,G,B triple"),
|
||||
PNG_INDEXED_COLOUR(3, 1, "1, 2, 4, 8", "Indexed-colour", "Each pixel is a palette index; a PLTE chunk shall appear."),
|
||||
PNG_GREYSCALE_WITH_ALPHA(4, 2, "4, 8, 16", "Greyscale with alpha", "Each pixel is a greyscale sample followed by an alpha sample."),
|
||||
PNG_TRUECOLOUR_WITH_ALPHA(6, 4, "8, 16", "Truecolour with alpha", "Each pixel is an R,G,B triple followed by an alpha sample.");
|
||||
|
||||
public final int code;
|
||||
public final int componentsPerPixel;
|
||||
public final String allowedBitDepths;
|
||||
public final String name;
|
||||
public final String descriptino;
|
||||
|
||||
PngColourType(int code, int componentsPerPixel, String allowedBitDepths, String name, String descriptino) {
|
||||
this.code = code;
|
||||
this.componentsPerPixel = componentsPerPixel;
|
||||
this.allowedBitDepths = allowedBitDepths;
|
||||
this.name = name;
|
||||
this.descriptino = descriptino;
|
||||
}
|
||||
|
||||
public boolean isIndexed() {
|
||||
return (code & 0x01) > 0;
|
||||
}
|
||||
|
||||
public boolean hasAlpha() {
|
||||
return (code & 0x04) > 0;
|
||||
}
|
||||
|
||||
public boolean supportsSubByteDepth() {
|
||||
return code == 0 || code == 3;
|
||||
}
|
||||
|
||||
public static PngColourType fromByte(byte b) throws PngException {
|
||||
switch (b) {
|
||||
case 0:
|
||||
return PNG_GREYSCALE;
|
||||
case 2:
|
||||
return PNG_TRUECOLOUR;
|
||||
case 3:
|
||||
return PNG_INDEXED_COLOUR;
|
||||
case 4:
|
||||
return PNG_GREYSCALE_WITH_ALPHA;
|
||||
case 6:
|
||||
return PNG_TRUECOLOUR_WITH_ALPHA;
|
||||
default:
|
||||
throw new PngIntegrityException(String.format("Valid PNG colour types are 0, 2, 3, 4, 6. Type '%d' is invalid", b));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// Copy from https://github.com/aellerton/japng
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package org.jackhuang.hmcl.ui.image.apng;
|
||||
|
||||
|
||||
public class PngConstants {
|
||||
|
||||
public static final int LENGTH_SIGNATURE = 8;
|
||||
public static final int LENGTH_fcTL_CHUNK = 4 + 4 + 4 + 4 + 4 + 2 + 2 + 1 + 1;
|
||||
public static final int LENGTH_acTL_CHUNK = 4 + 4;
|
||||
|
||||
public static final byte[] BYTES_SIGNATURE = new byte[]{
|
||||
(byte) 0x89, 'P', 'N', 'G', (byte) 0x0D, (byte) 0x0A, (byte) 0x1A, (byte) 0x0A
|
||||
};
|
||||
public static final int IHDR_VALUE = 'I' << 24 | 'H' << 16 | 'D' << 8 | 'R'; // 1229472850;
|
||||
public static final int PLTE_VALUE = 'P' << 24 | 'L' << 16 | 'T' << 8 | 'E';
|
||||
public static final int IDAT_VALUE = 'I' << 24 | 'D' << 16 | 'A' << 8 | 'T'; // 1229209940;
|
||||
public static final int IEND_VALUE = 'I' << 24 | 'E' << 16 | 'N' << 8 | 'D'; // 1229278788;
|
||||
public static final int gAMA_VALUE = 'g' << 24 | 'A' << 16 | 'M' << 8 | 'A'; // 1732332865;
|
||||
public static final int bKGD_VALUE = 'b' << 24 | 'K' << 16 | 'G' << 8 | 'D'; // 1649100612;
|
||||
public static final int tRNS_VALUE = 't' << 24 | 'R' << 16 | 'N' << 8 | 'S';
|
||||
public static final int acTL_VALUE = 'a' << 24 | 'c' << 16 | 'T' << 8 | 'L';
|
||||
public static final int fcTL_VALUE = 'f' << 24 | 'c' << 16 | 'T' << 8 | 'L';
|
||||
public static final int fdAT_VALUE = 'f' << 24 | 'd' << 16 | 'A' << 8 | 'T';
|
||||
|
||||
public static final int ERROR_NOT_PNG = 1;
|
||||
public static final int ERROR_EOF = 2;
|
||||
public static final int ERROR_EOF_EXPECTED = 3;
|
||||
public static final int ERROR_UNKNOWN_IO_FAILURE = 4;
|
||||
public static final int ERROR_FEATURE_NOT_SUPPORTED = 5;
|
||||
public static final int ERROR_INTEGRITY = 6;
|
||||
|
||||
public static final int ONE_K = 1 << 10;
|
||||
public static final int DEFAULT_TARGET_BUFFER_SIZE = 32 * ONE_K;
|
||||
|
||||
public static final byte[] GREY_PALETTE_16 = new byte[]{
|
||||
(byte) 0x00, // 0
|
||||
(byte) 0x11, // 1
|
||||
(byte) 0x22, // 2
|
||||
(byte) 0x33, // 3
|
||||
(byte) 0x44, // 4
|
||||
(byte) 0x55, // 5
|
||||
(byte) 0x66, // 6
|
||||
(byte) 0x77, // 7
|
||||
(byte) 0x88, // 8
|
||||
(byte) 0x99, // 9
|
||||
(byte) 0xaa, // 10
|
||||
(byte) 0xbb, // 11
|
||||
(byte) 0xcc, // 12
|
||||
(byte) 0xdd, // 13
|
||||
(byte) 0xee, // 14
|
||||
(byte) 0xff, // 15
|
||||
};
|
||||
|
||||
// public static final byte[] MASKS_1 = new byte[] {
|
||||
// (byte)0x01, // bit 0
|
||||
// (byte)0x02, // bit 1
|
||||
// (byte)0x04, // bit 2
|
||||
// (byte)0x08, // bit 3
|
||||
// (byte)0x10, // bit 4
|
||||
// (byte)0x20, // bit 5
|
||||
// (byte)0x40, // bit 6
|
||||
// (byte)0x80, // bit 7
|
||||
// };
|
||||
//
|
||||
// public static final byte[] MASKS_2 = new byte[] {
|
||||
// (byte)0x03, // bit 0-1
|
||||
// (byte)0x0C, // bit 2-3
|
||||
// (byte)0x30, // bit 4-5
|
||||
// (byte)0xC0, // bit 6-7
|
||||
// };
|
||||
//
|
||||
// public static final byte[] MASKS_4 = new byte[] {
|
||||
// (byte)0x0F, // bit 0-3
|
||||
// (byte)0xF0, // bit 4-7
|
||||
// };
|
||||
|
||||
public static final byte[] SHIFTS_1 = new byte[]{
|
||||
(byte) 0, // bit 0
|
||||
(byte) 1, // bit 1
|
||||
(byte) 2, // bit 2
|
||||
(byte) 3, // bit 3
|
||||
(byte) 4, // bit 4
|
||||
(byte) 5, // bit 5
|
||||
(byte) 6, // bit 6
|
||||
(byte) 7, // bit 7
|
||||
};
|
||||
|
||||
public static final byte[] SHIFTS_2 = new byte[]{
|
||||
(byte) 0, // bit 0-1
|
||||
(byte) 2, // bit 2-3
|
||||
(byte) 4, // bit 4-5
|
||||
(byte) 6 // bit 6-7
|
||||
};
|
||||
|
||||
public static final byte[] SHIFTS_4 = new byte[]{
|
||||
(byte) 0x00, // bit 0-3
|
||||
(byte) 0x04, // bit 4-7
|
||||
};
|
||||
|
||||
public static final int BIT_CHUNK_IS_ANCILLARY = 0x20000000;
|
||||
public static final int BIT_CHUNK_IS_PRIVATE = 0x00200000;
|
||||
public static final int BIT_CHUNK_IS_RESERVED = 0x00002000;
|
||||
public static final int BIT_CHUNK_IS_SAFE_TO_COPY = 0x00000020;
|
||||
|
||||
|
||||
// public static final byte[] SHIFTS_1 = new byte[] {
|
||||
// (byte)0x01, // bit 0
|
||||
// (byte)0x02, // bit 1
|
||||
// (byte)0x04, // bit 2
|
||||
// (byte)0x08, // bit 3
|
||||
// (byte)0x10, // bit 4
|
||||
// (byte)0x20, // bit 5
|
||||
// (byte)0x40, // bit 6
|
||||
// (byte)0x80, // bit 7
|
||||
// };
|
||||
|
||||
//public static final PngChunkCodes Foo = new PngChunkCodes(123, "Foo");
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// Copy from https://github.com/aellerton/japng
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package org.jackhuang.hmcl.ui.image.apng;
|
||||
|
||||
/**
|
||||
* Each "undo*" function reverses the effect of a filter on a given single scanline.
|
||||
*/
|
||||
public class PngFilter {
|
||||
|
||||
public static void undoSubFilter(byte[] bytes, int pixelStart, int pixelEnd, int filterUnit, byte[] previousRow) {
|
||||
// int ai = 0;
|
||||
// for (int i=pixelStart+filterUnit; i<pixelEnd; i++) {
|
||||
//
|
||||
// final int x = bytes[i];
|
||||
// final int a = bytes[pixelStart + ai];
|
||||
int ai = pixelStart;
|
||||
for (int i = pixelStart + filterUnit; i < pixelEnd; i++) {
|
||||
|
||||
final int x = bytes[i];
|
||||
final int a = bytes[ai];
|
||||
|
||||
//bytes[rowPosition] = (byte)((bytes[rowPosition] + left) & 0xff); // TODO & 0xff
|
||||
bytes[i] = (byte) ((x + a) & 0xff);
|
||||
|
||||
ai++;
|
||||
}
|
||||
}
|
||||
|
||||
public static void undoUpFilter(byte[] bytes, int pixelStart, int pixelEnd, int filterUnit, byte[] previousRow) {
|
||||
// for (int i=1; i<bytesPerLine; i++) {
|
||||
// rowPosition++; // before the first op to skip filter byte
|
||||
// bytes[rowPosition] = (byte)((bytes[rowPosition] + previousRow[i]) & 0xff);
|
||||
// }
|
||||
int bi = 0;
|
||||
for (int i = pixelStart; i < pixelEnd; i++) {
|
||||
final int x = bytes[i];
|
||||
final int b = previousRow[bi];
|
||||
bytes[i] = (byte) ((x + b) & 0xff);
|
||||
bi++;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public static void undoAverageFilter(byte[] bytes, int pixelStart, int pixelEnd, int filterUnit, byte[] previousRow) {
|
||||
int ai = pixelStart - filterUnit;
|
||||
int bi = 0;
|
||||
for (int i = pixelStart; i < pixelEnd; i++) {
|
||||
final int x = bytes[i];
|
||||
final int a = (ai < pixelStart) ? 0 : (0xff & bytes[ai]);
|
||||
final int b = (0xff & previousRow[bi]);
|
||||
// int ai = pixelStart;
|
||||
// int bi = 0;
|
||||
// for (int i=pixelStart+filterUnit; i<pixelEnd; i++) {
|
||||
// final int x = bytes[i];
|
||||
// final int a = bytes[ai];
|
||||
// final int b = previousRow[bi];
|
||||
|
||||
//bytes[i] = (byte)((x+((a+b)>>1))&0xff);
|
||||
//int z = x+(a+b)/2;
|
||||
int z = x + ((a + b) / 2);
|
||||
bytes[i] = (byte) (0xff & z);
|
||||
ai++;
|
||||
bi++;
|
||||
}
|
||||
|
||||
/*
|
||||
int bi = 0;
|
||||
for (int i=pixelStart; i<pixelStart+filterUnit; i++) {
|
||||
final int x = bytes[i];
|
||||
final int b = previousRow[bi];
|
||||
bytes[i] = (byte)((x + (b >> 1)) & 0xff);
|
||||
bi++;
|
||||
}
|
||||
|
||||
for (int i=pixelStart+filterUnit; i<pixelEnd; i++) {
|
||||
final int x = bytes[i];
|
||||
final int a = bytes[pixelStart+bi-filterUnit];
|
||||
final int b = previousRow[bi];
|
||||
bytes[i] = (byte)((x + ((a+b) >> 1)) & 0xff);
|
||||
bi++;
|
||||
}*/
|
||||
}
|
||||
|
||||
/**
|
||||
* See http://www.libpng.org/pub/png/spec/1.2/PNG-Filters.html
|
||||
*
|
||||
*/
|
||||
public static void undoPaethFilter(byte[] bytes, int pixelStart, int pixelEnd, int filterUnit, byte[] previousRow) {
|
||||
//int scratch;
|
||||
//int previousLeft = 0;
|
||||
//int left = 0;
|
||||
|
||||
|
||||
int ai = pixelStart - filterUnit;
|
||||
int bi = 0;
|
||||
int ci = -filterUnit;
|
||||
//for (int i=0; i<bytesPerLine-1; i++) {
|
||||
//for (int i=filterUnit; i<bytesPerLine-1; i++) {
|
||||
for (int i = pixelStart; i < pixelEnd; i++) {
|
||||
|
||||
final int a, b, c, x;
|
||||
x = bytes[i];
|
||||
if (ai < pixelStart) {
|
||||
a = c = 0;
|
||||
} else {
|
||||
a = 0xff & bytes[ai];
|
||||
c = 0xff & previousRow[ci];
|
||||
}
|
||||
b = 0xff & previousRow[bi];
|
||||
final int p = a + b - c;
|
||||
final int pa = p >= a ? p - a : a - p;
|
||||
final int pb = p >= b ? p - b : b - p;
|
||||
final int pc = p >= c ? p - c : c - p;
|
||||
final int predicted = (pa <= pb && pa <= pc) ? a
|
||||
: (pb <= pc) ? b
|
||||
: c;
|
||||
|
||||
bytes[i] = (byte) ((x + predicted) & 0xff);
|
||||
ai++;
|
||||
bi++;
|
||||
ci++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// Copy from https://github.com/aellerton/japng
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package org.jackhuang.hmcl.ui.image.apng;
|
||||
|
||||
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
|
||||
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
|
||||
import org.jackhuang.hmcl.ui.image.apng.reader.PngScanlineProcessor;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* A PngScanlineBuffer allows up-front once-off memory allocation that can be used
|
||||
* across an entire PNG (no matter how many frames) to accumulate and process image data.
|
||||
*/
|
||||
public class PngScanlineBuffer {
|
||||
|
||||
protected final byte[] bytes;
|
||||
protected final int filterUnit;
|
||||
|
||||
protected byte[] previousLine;
|
||||
protected int readPosition;
|
||||
protected int writePosition;
|
||||
//protected int numBytesPerLine;
|
||||
|
||||
public PngScanlineBuffer(int size, int maxBytesPerLine, int filterUnit) {
|
||||
this.bytes = new byte[size];
|
||||
this.previousLine = new byte[maxBytesPerLine];
|
||||
//this.scratchLine = new byte[maxBytesPerLine];
|
||||
//this.numBytesPerLine = numBytesPerLine;
|
||||
this.filterUnit = filterUnit;
|
||||
reset();
|
||||
}
|
||||
|
||||
public byte[] getBytes() {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
public int getReadPosition() {
|
||||
return readPosition;
|
||||
}
|
||||
|
||||
public int getWritePosition() {
|
||||
return writePosition;
|
||||
}
|
||||
|
||||
public boolean canRead(int minNumBytes) {
|
||||
return availableRead() >= minNumBytes;
|
||||
}
|
||||
|
||||
public int availableRead() {
|
||||
return writePosition - readPosition;
|
||||
}
|
||||
|
||||
public int availableWrite() {
|
||||
return bytes.length - writePosition;
|
||||
}
|
||||
|
||||
public void skip(int numBytes) {
|
||||
if (numBytes < 0 || numBytes > availableRead()) {
|
||||
throw new IllegalArgumentException(String.format("Skip bytes must be 0 > n >= %d: %d", availableRead(), numBytes));
|
||||
}
|
||||
readPosition += numBytes;
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
this.readPosition = 0;
|
||||
this.writePosition = 0;
|
||||
Arrays.fill(previousLine, (byte) 0);
|
||||
}
|
||||
|
||||
public void shift() {
|
||||
// It is difficult to say whether there is a more efficient approach - e.g. a for loop -
|
||||
// to shifting the bytes to the start of the array. For now, I consider System.arraycopy
|
||||
// to be "good enough".
|
||||
int len = availableRead();
|
||||
System.arraycopy(bytes, readPosition, bytes, 0, len);
|
||||
writePosition = len;
|
||||
readPosition = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the decompressed data into the buffer.
|
||||
*
|
||||
* @param is an InputStream yielding decompressed data.
|
||||
* @return count of how many bytes read, or -1 if EOF hit.
|
||||
* @throws IOException
|
||||
*/
|
||||
public int read(InputStream is) throws IOException {
|
||||
int r = is.read(bytes, writePosition, availableWrite());
|
||||
if (r > 0) {
|
||||
writePosition += r;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
public boolean decompress(InputStream inputStream, PngScanlineProcessor processor) throws IOException, PngException {
|
||||
int bytesPerLine = processor.getBytesPerLine();
|
||||
try (InputStream iis = processor.makeInflaterInputStream(inputStream)) {
|
||||
while (true) {
|
||||
// Ally says
|
||||
// I LOVE Y.O.U.
|
||||
|
||||
int len = read(iis);
|
||||
if (len <= 0) {
|
||||
return processor.isFinished();
|
||||
}
|
||||
|
||||
// Or could do: len -= bytesPerLine until len < bytesPerLine
|
||||
while (canRead(bytesPerLine)) {
|
||||
undoFilterScanline(bytesPerLine);
|
||||
processor.processScanline(bytes, readPosition + 1);//(), getReadPosition());
|
||||
skip(bytesPerLine);
|
||||
}
|
||||
shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void undoFilterScanline(int bytesPerLine) {
|
||||
int filterCode = bytes[readPosition];
|
||||
switch (filterCode) {
|
||||
case 0:
|
||||
// NOP
|
||||
break;
|
||||
case 1:
|
||||
PngFilter.undoSubFilter(bytes, readPosition + 1, readPosition + bytesPerLine, filterUnit, previousLine);
|
||||
break;
|
||||
case 2:
|
||||
PngFilter.undoUpFilter(bytes, readPosition + 1, readPosition + bytesPerLine, filterUnit, previousLine);
|
||||
break;
|
||||
case 3:
|
||||
PngFilter.undoAverageFilter(bytes, readPosition + 1, readPosition + bytesPerLine, filterUnit, previousLine);
|
||||
break;
|
||||
case 4:
|
||||
PngFilter.undoPaethFilter(bytes, readPosition + 1, readPosition + bytesPerLine, filterUnit, previousLine);
|
||||
break;
|
||||
default: // I toyed with an exception here. But why? Just treat as if no filter.
|
||||
//throw new IllegalArgumentException(String.format("Filter type %d invalid; valid is 0..4", filterCode);
|
||||
break;
|
||||
}
|
||||
|
||||
// when un-filtering scanlines, the previous row is a copy of the *un-filtered* row (not the original).
|
||||
System.arraycopy(bytes, readPosition + 1, previousLine, 0, bytesPerLine - 1); // keep a copy of the unmodified row
|
||||
}
|
||||
|
||||
public static PngScanlineBuffer from(PngHeader header) {
|
||||
return from(header, PngConstants.DEFAULT_TARGET_BUFFER_SIZE);
|
||||
}
|
||||
|
||||
public static PngScanlineBuffer from(PngHeader header, int minBufferSize) {
|
||||
return new PngScanlineBuffer(sizeFrom(header, minBufferSize), header.bytesPerRow, header.filterOffset);
|
||||
}
|
||||
|
||||
public static int sizeFrom(PngHeader header, int minBufferSize) {
|
||||
int bytesPerRow = header.bytesPerRow;
|
||||
int bytesFullBitmap = bytesPerRow * header.height;
|
||||
if (bytesFullBitmap < minBufferSize) {
|
||||
return bytesFullBitmap;
|
||||
}
|
||||
int numRows = Math.max(1, minBufferSize / bytesPerRow);
|
||||
return numRows * bytesPerRow;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copy from https://github.com/aellerton/japng
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package org.jackhuang.hmcl.ui.image.apng.argb8888;
|
||||
|
||||
/**
|
||||
* A bitmap where each pixel is represented by 8-bits for alpha, red, green and blue
|
||||
* respectively, and each pixel stored in a single 32-bit integer. This is expressly designed
|
||||
* to be compatible with the input array used to build Android Bitmap objects, though of course
|
||||
* its use is not limited to that.
|
||||
*/
|
||||
public final class Argb8888Bitmap {
|
||||
public final int[] array;
|
||||
public final int width;
|
||||
public final int height;
|
||||
|
||||
public Argb8888Bitmap(int width, int height) {
|
||||
this(new int[width * height], width, height);
|
||||
}
|
||||
|
||||
public Argb8888Bitmap(int[] array, int width, int height) {//}, int x, int y) {
|
||||
this.array = array;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
// this.x = y;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Bitmap that shares the byte array of this bitmap.
|
||||
* This is useful when rendering a number of animation frames from an APNG file
|
||||
* and wanting to minimise memory allocation, while still wanting a "new" bitmap
|
||||
* to work on.
|
||||
*
|
||||
* @param width in pixels of "new" bitmap (actual pixel array is the exact array
|
||||
* from the original bitmap).
|
||||
* @param height in pixels of "new" bitmap (actual pixel array is the exact array
|
||||
* from the original bitmap).
|
||||
* @return new bitmap object sharing the same data array.
|
||||
*/
|
||||
public Argb8888Bitmap makeView(int width, int height) {//}, int x, int y) {
|
||||
if ((width * height) > (this.width * this.height)) {
|
||||
throw new IllegalArgumentException(String.format(
|
||||
"Requested width and height (%d x %d) exceeds maximum pixels allowed by host bitmap (%d x %d",
|
||||
width, height, this.width, this.height));
|
||||
}
|
||||
return new Argb8888Bitmap(array, width, height);
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
// Copy from https://github.com/aellerton/japng
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package org.jackhuang.hmcl.ui.image.apng.argb8888;
|
||||
|
||||
import org.jackhuang.hmcl.ui.image.apng.chunks.PngAnimationControl;
|
||||
import org.jackhuang.hmcl.ui.image.apng.chunks.PngFrameControl;
|
||||
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* An Argb8888BitmapSequence object represents all bitmaps in a single PNG file,
|
||||
* whether it has one and only one default image, any number of frames in an animation
|
||||
* or a default image and a separate set of animation frames.
|
||||
* <p>
|
||||
* Note that instances of this class will hold an individual bitmap for every frame
|
||||
* and does <em>not</em> do composition of images in any way. Composition is done in
|
||||
* the japng_android library using an Android Canvas. This class is not used for that,
|
||||
* as the intermediate Argb8888Bitmap objects are not required, only one bitmap and
|
||||
* the output buffer is required during composition.
|
||||
*/
|
||||
public final class Argb8888BitmapSequence {
|
||||
|
||||
public final PngHeader header;
|
||||
public final Argb8888Bitmap defaultImage;
|
||||
|
||||
private boolean defaultImageIsSet = false;
|
||||
private PngAnimationControl animationControl;
|
||||
List<Frame> animationFrames;
|
||||
|
||||
public Argb8888BitmapSequence(PngHeader header) {
|
||||
this.header = header;
|
||||
this.defaultImage = new Argb8888Bitmap(header.width, header.height);
|
||||
}
|
||||
|
||||
public void receiveAnimationControl(PngAnimationControl animationControl) {
|
||||
this.animationControl = animationControl;
|
||||
this.animationFrames = new ArrayList<>(animationControl.numFrames);
|
||||
}
|
||||
|
||||
public void receiveDefaultImage(Argb8888Bitmap bitmap) {
|
||||
defaultImageIsSet = true;
|
||||
}
|
||||
|
||||
public boolean hasDefaultImage() {
|
||||
return defaultImageIsSet;
|
||||
}
|
||||
|
||||
public boolean isAnimated() {
|
||||
return null != animationControl && animationControl.numFrames > 0;
|
||||
}
|
||||
|
||||
public PngAnimationControl getAnimationControl() {
|
||||
return animationControl;
|
||||
}
|
||||
|
||||
public List<Frame> getAnimationFrames() {
|
||||
return animationFrames;
|
||||
}
|
||||
|
||||
public static final class Frame {
|
||||
public final PngFrameControl control;
|
||||
public final Argb8888Bitmap bitmap;
|
||||
|
||||
public Frame(PngFrameControl control, Argb8888Bitmap bitmap) {
|
||||
this.control = control;
|
||||
this.bitmap = bitmap;
|
||||
}
|
||||
}
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
// Copy from https://github.com/aellerton/japng
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package org.jackhuang.hmcl.ui.image.apng.argb8888;
|
||||
|
||||
import org.jackhuang.hmcl.ui.image.apng.PngScanlineBuffer;
|
||||
import org.jackhuang.hmcl.ui.image.apng.chunks.PngAnimationControl;
|
||||
import org.jackhuang.hmcl.ui.image.apng.chunks.PngFrameControl;
|
||||
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
|
||||
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
|
||||
|
||||
/**
|
||||
* Argb8888BitmapSequenceDirector instances direct an Argb8888Processor to build all frames
|
||||
* of an animation into an Argb8888BitmapSequence object.
|
||||
*/
|
||||
public class Argb8888BitmapSequenceDirector extends BasicArgb8888Director<Argb8888BitmapSequence> {
|
||||
Argb8888BitmapSequence bitmapSequence = null;
|
||||
PngFrameControl currentFrame = null;
|
||||
private PngHeader header;
|
||||
|
||||
@Override
|
||||
public void receiveHeader(PngHeader header, PngScanlineBuffer buffer) throws PngException {
|
||||
this.header = header;
|
||||
this.bitmapSequence = new Argb8888BitmapSequence(header);
|
||||
//this.header = header;
|
||||
//defaultImage = new Argb8888Bitmap(header.width, header.height);
|
||||
this.scanlineProcessor = Argb8888Processors.from(header, buffer, this.bitmapSequence.defaultImage);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean wantDefaultImage() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean wantAnimationFrames() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Argb8888ScanlineProcessor beforeDefaultImage() {
|
||||
return scanlineProcessor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void receiveDefaultImage(Argb8888Bitmap bitmap) {
|
||||
this.bitmapSequence.receiveDefaultImage(bitmap);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void receiveAnimationControl(PngAnimationControl control) {
|
||||
this.bitmapSequence.receiveAnimationControl(control);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Argb8888ScanlineProcessor receiveFrameControl(PngFrameControl control) {
|
||||
//throw new IllegalStateException("TODO up to here");
|
||||
//return null;
|
||||
currentFrame = control;
|
||||
|
||||
//System.out.println("Frame: "+control);
|
||||
|
||||
return scanlineProcessor.cloneWithNewBitmap(header.adjustFor(control)); // TODO: is this going to be a problem?
|
||||
}
|
||||
|
||||
@Override
|
||||
public void receiveFrameImage(Argb8888Bitmap bitmap) {
|
||||
if (null == currentFrame) {
|
||||
throw new IllegalStateException("Received a frame image with no frame control in place");
|
||||
}
|
||||
if (null == bitmapSequence.animationFrames) {
|
||||
throw new IllegalStateException("Received a frame image without animation control (or frame list?) in place");
|
||||
}
|
||||
bitmapSequence.animationFrames.add(new Argb8888BitmapSequence.Frame(currentFrame, bitmap));
|
||||
currentFrame = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Argb8888BitmapSequence getResult() {
|
||||
return bitmapSequence;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copy from https://github.com/aellerton/japng
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package org.jackhuang.hmcl.ui.image.apng.argb8888;
|
||||
|
||||
import org.jackhuang.hmcl.ui.image.apng.PngScanlineBuffer;
|
||||
import org.jackhuang.hmcl.ui.image.apng.chunks.PngAnimationControl;
|
||||
import org.jackhuang.hmcl.ui.image.apng.chunks.PngFrameControl;
|
||||
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
|
||||
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
|
||||
|
||||
/**
|
||||
* Argb8888Director implementations "direct" a given Argb8888Processor how to
|
||||
* control the output. This allows the Argb8888Processor to transform pixels into
|
||||
* ARGB8888 format while allowing for radically different final output objects,
|
||||
* e.g. a single bitmap, a sequence of bitamps, an Android View or Drawable, etc.
|
||||
* <p>
|
||||
* TODO: not sure if this will stay in this form. Needs refinement.
|
||||
*/
|
||||
public interface Argb8888Director<ResultT> {
|
||||
|
||||
void receiveHeader(PngHeader header, PngScanlineBuffer buffer) throws PngException;
|
||||
|
||||
void receivePalette(Argb8888Palette palette);
|
||||
|
||||
void processTransparentPalette(byte[] bytes, int position, int length) throws PngException;
|
||||
|
||||
void processTransparentGreyscale(byte k1, byte k0) throws PngException;
|
||||
|
||||
void processTransparentTruecolour(byte r1, byte r0, byte g1, byte g0, byte b1, byte b0) throws PngException;
|
||||
|
||||
boolean wantDefaultImage();
|
||||
|
||||
boolean wantAnimationFrames();
|
||||
|
||||
Argb8888ScanlineProcessor beforeDefaultImage();
|
||||
|
||||
void receiveDefaultImage(Argb8888Bitmap bitmap);
|
||||
|
||||
void receiveAnimationControl(PngAnimationControl control);
|
||||
|
||||
Argb8888ScanlineProcessor receiveFrameControl(PngFrameControl control);
|
||||
|
||||
void receiveFrameImage(Argb8888Bitmap bitmap);
|
||||
|
||||
ResultT getResult();
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// Copy from https://github.com/aellerton/japng
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package org.jackhuang.hmcl.ui.image.apng.argb8888;
|
||||
|
||||
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
|
||||
import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
|
||||
|
||||
/**
|
||||
* A palette implementation representing colours in ARGB8888 format as 32-bit integers.
|
||||
* <p>
|
||||
* Note that the transparency of individual entries in the palette is modified in-place
|
||||
* when the trNS chunk is processed.
|
||||
*
|
||||
* @see BasicArgb8888Director
|
||||
*/
|
||||
public class Argb8888Palette {
|
||||
protected static Argb8888Palette monochromePalette = null;
|
||||
protected static Argb8888Palette greyPalette2 = null;
|
||||
protected static Argb8888Palette greyPalette4 = null;
|
||||
protected static Argb8888Palette greyPalette8 = null;
|
||||
|
||||
/**
|
||||
* The colour array is public and mutable by design.
|
||||
*
|
||||
* @see this.get()
|
||||
*/
|
||||
public final int[] argbArray;
|
||||
|
||||
/**
|
||||
* Construct a new palette using the specific array passed as the argument as-is (not copied).
|
||||
*
|
||||
* @param argbArray array of colours to use as-is (it is not copied).
|
||||
*/
|
||||
public Argb8888Palette(int[] argbArray) {
|
||||
this.argbArray = argbArray;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a colour using a method. Users can also just use the argbArray directly.
|
||||
*
|
||||
* @param index of colour to retrieve
|
||||
* @return integer representing the colour at that index.
|
||||
*/
|
||||
public int get(int index) {
|
||||
return this.argbArray[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the number of colours in this palette, which is the same thing as the size of the array.
|
||||
*/
|
||||
public int size() {
|
||||
return argbArray.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a new palette by reading the byte array (from the offset position and for length bytes)
|
||||
* as provided in a PLTE chunk.
|
||||
*
|
||||
* @param bytes array to read data from
|
||||
* @param position offset into bytes array to begin reading from
|
||||
* @param length number of bytes to read from bytes array
|
||||
* @return new palette formed by reading the bytes array.
|
||||
* @throws PngException
|
||||
*/
|
||||
public static Argb8888Palette fromPaletteBytes(byte[] bytes, int position, int length) throws PngException {
|
||||
int numColours = length / 3; // guaranteed to be divisible by 3
|
||||
int[] argbArray = new int[numColours];
|
||||
int srcIndex = position;
|
||||
int alpha = 0xff << 24;
|
||||
for (int destIndex = 0; destIndex < numColours; destIndex++) {
|
||||
final int r = bytes[srcIndex++] & 0xff;
|
||||
final int g = bytes[srcIndex++] & 0xff;
|
||||
final int b = bytes[srcIndex++] & 0xff;
|
||||
argbArray[destIndex] = alpha | r << 16 | g << 8 | b;
|
||||
}
|
||||
return new Argb8888Palette(argbArray);
|
||||
}
|
||||
|
||||
public static Argb8888Palette forGreyscale(int numEntries, int step) {
|
||||
int[] array = new int[numEntries];
|
||||
int alpha = 0xff << 24;
|
||||
int grey = 0;
|
||||
for (int i = 0; i < numEntries; i++) {
|
||||
array[i] = alpha | grey << 16 | grey << 8 | grey;
|
||||
grey = (grey + step) & 0xff;
|
||||
}
|
||||
return new Argb8888Palette(array);
|
||||
}
|
||||
|
||||
public static Argb8888Palette forGreyscale(int bitDepth) throws PngException {
|
||||
switch (bitDepth) {
|
||||
case 1:
|
||||
if (null == monochromePalette) {
|
||||
monochromePalette = forGreyscale(2, 0xff); // Worth it or not really?
|
||||
}
|
||||
return monochromePalette;
|
||||
case 2:
|
||||
if (null == greyPalette2) {
|
||||
greyPalette2 = forGreyscale(4, 0x55);
|
||||
}
|
||||
return greyPalette2;
|
||||
case 4:
|
||||
if (null == greyPalette4) {
|
||||
greyPalette4 = forGreyscale(16, 0x11);
|
||||
}
|
||||
return greyPalette4;
|
||||
case 8: // TODO: need??
|
||||
if (null == greyPalette8) {
|
||||
greyPalette8 = forGreyscale(256, 0x01);
|
||||
}
|
||||
return greyPalette8;
|
||||
default:
|
||||
throw new PngIntegrityException(String.format("Valid greyscale bit depths are 1, 2, 4, 8, not %d", bitDepth));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
// Copy from https://github.com/aellerton/japng
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package org.jackhuang.hmcl.ui.image.apng.argb8888;
|
||||
|
||||
import org.jackhuang.hmcl.ui.image.apng.PngChunkCode;
|
||||
import org.jackhuang.hmcl.ui.image.apng.PngScanlineBuffer;
|
||||
import org.jackhuang.hmcl.ui.image.apng.chunks.PngAnimationControl;
|
||||
import org.jackhuang.hmcl.ui.image.apng.chunks.PngFrameControl;
|
||||
import org.jackhuang.hmcl.ui.image.apng.chunks.PngGamma;
|
||||
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
|
||||
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
|
||||
import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
|
||||
import org.jackhuang.hmcl.ui.image.apng.map.PngChunkMap;
|
||||
import org.jackhuang.hmcl.ui.image.apng.reader.PngChunkProcessor;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
/**
|
||||
* Concrete implementation of a chunk processor designed to process pixels into
|
||||
* 32-bit ARGB8888 format.
|
||||
*/
|
||||
public class Argb8888Processor<ResultT> implements PngChunkProcessor<ResultT> { //PngChunkProcessor<Argb8888Bitmap> {
|
||||
|
||||
protected PngHeader header = null;
|
||||
protected PngScanlineBuffer scanlineReader = null;
|
||||
protected Argb8888Director<ResultT> builder = null;
|
||||
protected Argb8888ScanlineProcessor scanlineProcessor = null;
|
||||
|
||||
|
||||
public Argb8888Processor(Argb8888Director<ResultT> builder) {
|
||||
this.builder = builder;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void processHeader(PngHeader header) throws PngException {
|
||||
// super.processHeader(header);
|
||||
//// if (header.bitDepth != 1 || header.colourType != PngColourType.PNG_GREYSCALE) {
|
||||
//// throw new PngFeatureException("ARGB888 only supports 1-bit greyscale");
|
||||
//// }
|
||||
// if (header.isGreyscale()) {
|
||||
// palette = Argb8888Palette.forGreyscale(header.bitDepth);
|
||||
// }
|
||||
// scanlineReader = PngScanlineBuffer.from(header);
|
||||
this.header = header;
|
||||
this.scanlineReader = PngScanlineBuffer.from(header);
|
||||
this.builder.receiveHeader(this.header, this.scanlineReader);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void processGamma(PngGamma gamma) throws PngException {
|
||||
// No gamma processing is done at the moment.
|
||||
}
|
||||
|
||||
@Override
|
||||
public void processPalette(byte[] bytes, int position, int length) throws PngException {
|
||||
//palette = Argb8888Palette.fromPaletteBytes(bytes, position, length);
|
||||
//scanlineProcessor = Argb8888Processors.fromPalette(this.header, palette);
|
||||
builder.receivePalette(Argb8888Palette.fromPaletteBytes(bytes, position, length));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void processTransparency(byte[] bytes, int position, int length) throws PngException {
|
||||
switch (header.colourType) {
|
||||
case PNG_GREYSCALE: // colour type 0
|
||||
// grey sample value (2 bytes)
|
||||
if (length != 2) {
|
||||
throw new PngIntegrityException(String.format("tRNS chunk for greyscale image must be exactly length=2, not %d", length));
|
||||
}
|
||||
builder.processTransparentGreyscale(bytes[0], bytes[1]);
|
||||
break;
|
||||
|
||||
case PNG_TRUECOLOUR: // colour type 2
|
||||
// red, green, blue samples, EACH with two bytes (16-bits)
|
||||
builder.processTransparentTruecolour(bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5]);
|
||||
break;
|
||||
|
||||
case PNG_INDEXED_COLOUR: // colour type 3
|
||||
// This is a sequence of one-byte alpha values to apply to each palette entry starting at zero.
|
||||
// The number of entries may be less than the size of the palette, but not more.
|
||||
builder.processTransparentPalette(bytes, position, length);
|
||||
break;
|
||||
|
||||
case PNG_GREYSCALE_WITH_ALPHA:
|
||||
case PNG_TRUECOLOUR_WITH_ALPHA:
|
||||
default:
|
||||
throw new PngIntegrityException("Illegal to have tRNS chunk with image type " + header.colourType.name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The only supported animation type is not "NOT_ANIMATED".
|
||||
*/
|
||||
// @Override
|
||||
// public PngAnimationType chooseApngImageType(PngAnimationType type, PngFrameControl currentFrame) throws PngException {
|
||||
// scanlineProcessor = Argb8888ScanlineProcessor.from(header, scanlineReader, currentFrame);
|
||||
// return PngAnimationType.NOT_ANIMATED;
|
||||
// }
|
||||
@Override
|
||||
public void processDefaultImageData(InputStream inputStream, PngChunkCode code, int position, int length) throws IOException, PngException {
|
||||
if (!builder.wantDefaultImage()) {
|
||||
inputStream.skip(length); // important!
|
||||
return;
|
||||
}
|
||||
|
||||
if (null == scanlineProcessor) { // TODO: is that a good enough metric? Or could be numIdat==0?
|
||||
scanlineProcessor = builder.beforeDefaultImage();
|
||||
if (null == scanlineProcessor) throw new IllegalStateException("Builder must create scanline processor");
|
||||
}
|
||||
|
||||
if (scanlineReader.decompress(inputStream, scanlineProcessor)) {
|
||||
// If here, the image is fully decompressed.
|
||||
builder.receiveDefaultImage(scanlineProcessor.getBitmap());
|
||||
scanlineProcessor = null;
|
||||
scanlineReader.reset();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void processAnimationControl(PngAnimationControl animationControl) throws PngException {
|
||||
if (builder.wantAnimationFrames()) {
|
||||
builder.receiveAnimationControl(animationControl);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void processFrameControl(PngFrameControl frameControl) throws PngException {
|
||||
if (!builder.wantAnimationFrames()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (null == scanlineProcessor) {
|
||||
// If here, the data in this frame image has not started
|
||||
scanlineProcessor = builder.receiveFrameControl(frameControl);
|
||||
if (null == scanlineProcessor)
|
||||
throw new IllegalStateException("Builder must create scanline processor for frame");
|
||||
} else {
|
||||
throw new IllegalStateException("received animation frame control but image data was in progress");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void processFrameImageData(InputStream inputStream, PngChunkCode code, int position, int length) throws IOException, PngException {
|
||||
//throw new PngFeatureException("PngArgb8888Processor does not support animation frames");
|
||||
if (!builder.wantAnimationFrames()) {
|
||||
inputStream.skip(length);
|
||||
return;
|
||||
}
|
||||
|
||||
if (null == scanlineProcessor) {
|
||||
throw new IllegalStateException("received animation frame image data before frame control or without processor in place");
|
||||
}
|
||||
|
||||
if (scanlineReader.decompress(inputStream, scanlineProcessor)) {
|
||||
// If here, the image is fully decompressed.
|
||||
builder.receiveFrameImage(scanlineProcessor.getBitmap());
|
||||
scanlineReader.reset();
|
||||
scanlineProcessor = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void processChunkMapItem(PngChunkMap chunkMapItem) throws PngException {
|
||||
// NOP
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResultT getResult() {
|
||||
//return scanlineProcessor.getBitmap();
|
||||
return builder.getResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
// Copy from https://github.com/aellerton/japng
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package org.jackhuang.hmcl.ui.image.apng.argb8888;
|
||||
|
||||
import org.jackhuang.hmcl.ui.image.apng.PngConstants;
|
||||
import org.jackhuang.hmcl.ui.image.apng.PngScanlineBuffer;
|
||||
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
|
||||
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
|
||||
import org.jackhuang.hmcl.ui.image.apng.error.PngFeatureException;
|
||||
import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
|
||||
|
||||
/**
|
||||
* A series of scanline processor implementations for different input pixel formats,
|
||||
* each able to transform the input into ARGB8888 output pixels.
|
||||
*/
|
||||
public class Argb8888Processors {
|
||||
|
||||
/**
|
||||
* Determine from the header the concrete Argb8888Processor implementation the
|
||||
* caller needs to transform the incoming pixels into the ARGB8888 pixel format.
|
||||
*
|
||||
* @param header of the image being loaded
|
||||
* @param scanlineReader the scanline buffer being used
|
||||
* @param bitmap the destination bitmap
|
||||
* @return a concrete Argb8888Processor to transform source pixels into the specific bitmap.
|
||||
* @throws PngException if there is a feature not supported or some specification break with the file.
|
||||
*/
|
||||
public static Argb8888ScanlineProcessor from(PngHeader header, PngScanlineBuffer scanlineReader, Argb8888Bitmap bitmap) throws PngException {
|
||||
|
||||
int bytesPerScanline = header.bytesPerRow;
|
||||
switch (header.colourType) {
|
||||
case PNG_GREYSCALE:
|
||||
switch (header.bitDepth) {
|
||||
case 1:
|
||||
return new IndexedColourBits(bytesPerScanline, bitmap, 7, 0x01, PngConstants.SHIFTS_1, Argb8888Palette.forGreyscale(1));
|
||||
case 2:
|
||||
return new IndexedColourBits(bytesPerScanline, bitmap, 3, 0x03, PngConstants.SHIFTS_2, Argb8888Palette.forGreyscale(2));
|
||||
case 4:
|
||||
return new IndexedColourBits(bytesPerScanline, bitmap, 1, 0x0F, PngConstants.SHIFTS_4, Argb8888Palette.forGreyscale(4));
|
||||
case 8:
|
||||
return new Greyscale8(bytesPerScanline, bitmap);
|
||||
case 16:
|
||||
throw new PngFeatureException("Greyscale supports 1, 2, 4, 8 but not 16.");
|
||||
default:
|
||||
throw new PngIntegrityException(String.format("Invalid greyscale bit-depth: %d", header.bitDepth)); // TODO: should be in header parse.
|
||||
}
|
||||
|
||||
case PNG_GREYSCALE_WITH_ALPHA:
|
||||
switch (header.bitDepth) {
|
||||
case 4:
|
||||
return new Greyscale4Alpha(bytesPerScanline, bitmap);
|
||||
case 8:
|
||||
return new Greyscale8Alpha(bytesPerScanline, bitmap);
|
||||
case 16:
|
||||
return new Greyscale16Alpha(bytesPerScanline, bitmap);
|
||||
default:
|
||||
throw new PngIntegrityException(String.format("Invalid greyscale-with-alpha bit-depth: %d", header.bitDepth)); // TODO: should be in header parse.
|
||||
}
|
||||
|
||||
case PNG_INDEXED_COLOUR:
|
||||
switch (header.bitDepth) {
|
||||
case 1:
|
||||
return new IndexedColourBits(bytesPerScanline, bitmap, 7, 0x01, PngConstants.SHIFTS_1);
|
||||
case 2:
|
||||
return new IndexedColourBits(bytesPerScanline, bitmap, 3, 0x03, PngConstants.SHIFTS_2);
|
||||
case 4:
|
||||
return new IndexedColourBits(bytesPerScanline, bitmap, 1, 0x0F, PngConstants.SHIFTS_4);
|
||||
case 8:
|
||||
return new IndexedColour8(bytesPerScanline, bitmap);
|
||||
default:
|
||||
throw new PngIntegrityException(String.format("Invalid indexed colour bit-depth: %d", header.bitDepth)); // TODO: should be in header parse.
|
||||
}
|
||||
|
||||
case PNG_TRUECOLOUR:
|
||||
switch (header.bitDepth) {
|
||||
case 8:
|
||||
return new Truecolour8(bytesPerScanline, bitmap);
|
||||
case 16:
|
||||
return new Truecolour16(bytesPerScanline, bitmap);
|
||||
default:
|
||||
throw new PngIntegrityException(String.format("Invalid truecolour bit-depth: %d", header.bitDepth)); // TODO: should be in header parse.
|
||||
|
||||
}
|
||||
|
||||
case PNG_TRUECOLOUR_WITH_ALPHA:
|
||||
switch (header.bitDepth) {
|
||||
case 8:
|
||||
return new Truecolour8Alpha(bytesPerScanline, bitmap);
|
||||
case 16:
|
||||
return new Truecolour16Alpha(bytesPerScanline, bitmap);
|
||||
default:
|
||||
throw new PngIntegrityException(String.format("Invalid truecolour with alpha bit-depth: %d", header.bitDepth)); // TODO: should be in header parse.
|
||||
|
||||
}
|
||||
|
||||
default:
|
||||
throw new PngFeatureException("ARGB8888 doesn't support PNG mode " + header.colourType.name());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms 1-, 2-, 4-bit indexed colour source pixels to ARGB8888 pixels.
|
||||
*/
|
||||
public static class IndexedColourBits extends Argb8888ScanlineProcessor {
|
||||
|
||||
private int highBit;
|
||||
private int mask;
|
||||
private byte[] shifts;
|
||||
|
||||
public IndexedColourBits(int bytesPerScanline, Argb8888Bitmap bitmap, int highBit, int mask, byte[] shifts) {
|
||||
this(bytesPerScanline, bitmap, highBit, mask, shifts, null);
|
||||
}
|
||||
|
||||
public IndexedColourBits(int bytesPerScanline, Argb8888Bitmap bitmap, int highBit, int mask, byte[] shifts, Argb8888Palette palette) {
|
||||
super(bytesPerScanline, bitmap);
|
||||
this.highBit = highBit;
|
||||
this.mask = mask;
|
||||
this.shifts = shifts;
|
||||
this.palette = palette;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void processScanline(byte[] srcBytes, int srcPosition) {
|
||||
final int[] destArray = this.bitmap.array;
|
||||
final int width = this.bitmap.width;
|
||||
int writePosition = this.y * width;
|
||||
int lastWritePosition = writePosition + width;
|
||||
int bit = highBit;
|
||||
|
||||
while (writePosition < lastWritePosition) {
|
||||
//final int index=(srcBytes[srcPosition+x] & masks[bit]) >> shifts[bit];
|
||||
//final int index = (srcBytes[srcPosition])
|
||||
//final int v = srcBytes[srcPosition];
|
||||
final int index = mask & (srcBytes[srcPosition] >> shifts[bit]);
|
||||
|
||||
int dest;
|
||||
if (palette != null) {
|
||||
dest = palette.argbArray[index];
|
||||
} else {
|
||||
dest = 0;
|
||||
}
|
||||
destArray[writePosition++] = dest;
|
||||
if (bit == 0) {
|
||||
srcPosition++;
|
||||
bit = highBit;
|
||||
} else {
|
||||
bit--;
|
||||
}
|
||||
}
|
||||
this.y++;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
|
||||
return new IndexedColourBits(bytesPerRow, bitmap, highBit, mask, shifts, palette);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Special case implementation to transform 8-bit indexed colour source pixels to ARGB8888 pixels.
|
||||
*/
|
||||
public static class IndexedColour8 extends Argb8888ScanlineProcessor {
|
||||
|
||||
public IndexedColour8(int bytesPerScanline, Argb8888Bitmap bitmap) {
|
||||
super(bytesPerScanline, bitmap);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void processScanline(byte[] srcBytes, int srcPosition) {
|
||||
final int[] destArray = this.bitmap.array;
|
||||
final int width = this.bitmap.width;
|
||||
int writePosition = this.y * width;
|
||||
for (int x = 0; x < width; x++) {
|
||||
|
||||
final int index = 0xff & srcBytes[srcPosition++]; // TODO: need to use transparency and background chunks
|
||||
|
||||
int dest;
|
||||
if (palette != null) {
|
||||
dest = palette.argbArray[index];
|
||||
} else {
|
||||
dest = 0;
|
||||
}
|
||||
destArray[writePosition++] = dest;
|
||||
}
|
||||
this.y++;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
|
||||
return new IndexedColour8(bytesPerRow, bitmap);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms 4-bit greyscale with alpha source pixels to ARGB8888 pixels.
|
||||
*/
|
||||
public static class Greyscale4Alpha extends Argb8888ScanlineProcessor {
|
||||
|
||||
public Greyscale4Alpha(int bytesPerScanline, Argb8888Bitmap bitmap) {
|
||||
super(bytesPerScanline, bitmap);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void processScanline(byte[] srcBytes, int srcPosition) {
|
||||
final int[] destArray = this.bitmap.array;
|
||||
final int width = this.bitmap.width;
|
||||
int writePosition = this.y * width;
|
||||
for (int x = 0; x < width; x++) {
|
||||
final int v = srcBytes[srcPosition++];
|
||||
final int k = PngConstants.GREY_PALETTE_16[0x0f & (v >> 4)];
|
||||
final int a = PngConstants.GREY_PALETTE_16[0x0f & v];
|
||||
final int c = a << 24 | k << 16 | k << 8 | k;
|
||||
destArray[writePosition++] = c;
|
||||
}
|
||||
this.y++;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
|
||||
return new Greyscale4Alpha(bytesPerRow, bitmap);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms 8-bit greyscale source pixels to ARGB8888 pixels.
|
||||
*/
|
||||
public static class Greyscale8 extends Argb8888ScanlineProcessor {
|
||||
|
||||
boolean haveTransparent = false;
|
||||
int transparentSample = 0;
|
||||
|
||||
public Greyscale8(int bytesPerScanline, Argb8888Bitmap bitmap) {
|
||||
super(bytesPerScanline, bitmap);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void processScanline(byte[] srcBytes, int srcPosition) {
|
||||
final int[] destArray = this.bitmap.array;
|
||||
final int width = this.bitmap.width;
|
||||
final int alpha = 0xff000000; // No alpha in the image means every pixel must be fully opaque
|
||||
int writePosition = this.y * width;
|
||||
for (int x = 0; x < width; x++) {
|
||||
final int sample = 0xff & srcBytes[srcPosition++];
|
||||
final int k = (haveTransparent && sample == transparentSample) ? transparentSample : sample;
|
||||
final int c = alpha | k << 16 | k << 8 | k;
|
||||
destArray[writePosition++] = c;
|
||||
}
|
||||
this.y++;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void processTransparentGreyscale(byte k1, byte k0) {
|
||||
// According to below, when image is less than 16-bits per pixel, use least significant byte
|
||||
// http://www.w3.org/TR/PNG/#11transinfo
|
||||
haveTransparent = true;
|
||||
transparentSample = 0xff & k0; // NOT k1 according to http://www.w3.org/TR/PNG/#11transinfo
|
||||
}
|
||||
|
||||
@Override
|
||||
public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
|
||||
return new Greyscale8(bytesPerRow, bitmap);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms 8-bit greyscale with alpha source pixels to ARGB8888 pixels.
|
||||
*/
|
||||
public static class Greyscale8Alpha extends Argb8888ScanlineProcessor {
|
||||
|
||||
public Greyscale8Alpha(int bytesPerScanline, Argb8888Bitmap bitmap) {
|
||||
super(bytesPerScanline, bitmap);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void processScanline(byte[] srcBytes, int srcPosition) {
|
||||
final int[] destArray = this.bitmap.array;
|
||||
final int width = this.bitmap.width;
|
||||
int writePosition = this.y * width;
|
||||
for (int x = 0; x < width; x++) {
|
||||
final int k = 0xff & srcBytes[srcPosition++];
|
||||
final int a = 0xff & srcBytes[srcPosition++];
|
||||
final int c = a << 24 | k << 16 | k << 8 | k;
|
||||
destArray[writePosition++] = c;
|
||||
}
|
||||
this.y++;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
|
||||
return new Greyscale8Alpha(bytesPerRow, bitmap);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms 16-bit greyscale with alpha source pixels to ARGB8888 pixels.
|
||||
*/
|
||||
public static class Greyscale16Alpha extends Argb8888ScanlineProcessor {
|
||||
|
||||
public Greyscale16Alpha(int bytesPerScanline, Argb8888Bitmap bitmap) {
|
||||
super(bytesPerScanline, bitmap);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void processScanline(byte[] srcBytes, int srcPosition) {
|
||||
final int[] destArray = this.bitmap.array;
|
||||
final int width = this.bitmap.width;
|
||||
int writePosition = this.y * width;
|
||||
for (int x = 0; x < width; x++) {
|
||||
final int k = 0xff & srcBytes[srcPosition++];
|
||||
srcPosition++; // skip the least-significant byte of the grey
|
||||
final int a = 0xff & srcBytes[srcPosition++];
|
||||
srcPosition++; // skip the least-significant byte of the alpha
|
||||
final int c = a << 24 | k << 16 | k << 8 | k;
|
||||
destArray[writePosition++] = c;
|
||||
}
|
||||
this.y++;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
|
||||
return new Greyscale16Alpha(bytesPerRow, bitmap);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms true-colour (RGB) 8-bit source pixels to ARGB8888 pixels.
|
||||
*/
|
||||
public static class Truecolour8 extends Argb8888ScanlineProcessor {
|
||||
public Truecolour8(int bytesPerScanline, Argb8888Bitmap bitmap) {
|
||||
super(bytesPerScanline, bitmap);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void processScanline(byte[] srcBytes, int srcPosition) {
|
||||
final int[] destArray = this.bitmap.array;
|
||||
final int width = this.bitmap.width;
|
||||
final int alpha = 0xff000000; // No alpha in the image means every pixel must be fully opaque
|
||||
int writePosition = this.y * width;
|
||||
for (int x = 0; x < width; x++) {
|
||||
final int r = 0xff & srcBytes[srcPosition++];
|
||||
final int g = 0xff & srcBytes[srcPosition++];
|
||||
final int b = 0xff & srcBytes[srcPosition++];
|
||||
final int c = alpha | r << 16 | g << 8 | b;
|
||||
destArray[writePosition++] = c;
|
||||
}
|
||||
this.y++;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
|
||||
return new Truecolour8(bytesPerRow, bitmap);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms true-colour with alpha (RGBA) 8-bit source pixels to ARGB8888 pixels.
|
||||
*/
|
||||
public static class Truecolour8Alpha extends Argb8888ScanlineProcessor {
|
||||
|
||||
public Truecolour8Alpha(int bytesPerScanline, Argb8888Bitmap bitmap) {
|
||||
super(bytesPerScanline, bitmap);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void processScanline(byte[] srcBytes, int srcPosition) {
|
||||
final int[] destArray = this.bitmap.array;
|
||||
final int width = this.bitmap.width;
|
||||
int writePosition = this.y * width;
|
||||
//srcPosition++; // skip filter byte
|
||||
for (int x = 0; x < width; x++) {
|
||||
final int r = 0xff & srcBytes[srcPosition++];
|
||||
final int g = 0xff & srcBytes[srcPosition++];
|
||||
final int b = 0xff & srcBytes[srcPosition++];
|
||||
final int a = 0xff & srcBytes[srcPosition++];
|
||||
final int c = a << 24 | r << 16 | g << 8 | b;
|
||||
destArray[writePosition++] = c;
|
||||
}
|
||||
this.y++;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
|
||||
return new Truecolour8Alpha(bytesPerRow, bitmap);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms true-colour (RGB) 16-bit source pixels to ARGB8888 pixels.
|
||||
*/
|
||||
public static class Truecolour16 extends Argb8888ScanlineProcessor {
|
||||
public Truecolour16(int bytesPerScanline, Argb8888Bitmap bitmap) {
|
||||
super(bytesPerScanline, bitmap);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void processScanline(byte[] srcBytes, int srcPosition) {
|
||||
final int[] destArray = this.bitmap.array;
|
||||
final int width = this.bitmap.width;
|
||||
final int alpha = 0xff000000; // No alpha in the image means every pixel must be fully opaque
|
||||
int writePosition = this.y * width;
|
||||
//srcPosition++; // skip filter byte
|
||||
for (int x = 0; x < width; x++) {
|
||||
final int r = 0xff & srcBytes[srcPosition];
|
||||
srcPosition += 2; // skip the byte just read and the least significant byte of the next
|
||||
final int g = 0xff & srcBytes[srcPosition];
|
||||
srcPosition += 2; // ditto
|
||||
final int b = 0xff & srcBytes[srcPosition];
|
||||
srcPosition += 2; // ditto again
|
||||
final int c = alpha | r << 16 | g << 8 | b;
|
||||
destArray[writePosition++] = c;
|
||||
}
|
||||
this.y++;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
|
||||
return new Truecolour16(bytesPerRow, bitmap);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms true-colour with alpha (RGBA) 16-bit source pixels to ARGB8888 pixels.
|
||||
* <p>
|
||||
* Note that the simpler method of resampling the colour is done, namely discard the LSB.
|
||||
*/
|
||||
public static class Truecolour16Alpha extends Argb8888ScanlineProcessor {
|
||||
public Truecolour16Alpha(int bytesPerScanline, Argb8888Bitmap bitmap) {
|
||||
super(bytesPerScanline, bitmap);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void processScanline(byte[] srcBytes, int srcPosition) {
|
||||
final int[] destArray = this.bitmap.array;
|
||||
final int width = this.bitmap.width;
|
||||
//final int alpha = 0xff000000; // No alpha in the image means every pixel must be fully opaque
|
||||
int writePosition = this.y * width;
|
||||
//srcPosition++; // skip filter byte
|
||||
for (int x = 0; x < width; x++) {
|
||||
final int r = 0xff & srcBytes[srcPosition];
|
||||
srcPosition += 2; // skip the byte just read and the least significant byte of the next
|
||||
final int g = 0xff & srcBytes[srcPosition];
|
||||
srcPosition += 2; // ditto
|
||||
final int b = 0xff & srcBytes[srcPosition];
|
||||
srcPosition += 2; // ditto again
|
||||
final int alpha = 0xff & srcBytes[srcPosition];
|
||||
srcPosition += 2; // skip the byte just read and the least significant byte of the next
|
||||
final int c = alpha << 24 | r << 16 | g << 8 | b;
|
||||
destArray[writePosition++] = c;
|
||||
}
|
||||
this.y++;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
|
||||
return new Truecolour16Alpha(bytesPerRow, bitmap);
|
||||
}
|
||||
}
|
||||
|
||||
private Argb8888Processors() {
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
// Copy from https://github.com/aellerton/japng
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package org.jackhuang.hmcl.ui.image.apng.argb8888;
|
||||
|
||||
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
|
||||
import org.jackhuang.hmcl.ui.image.apng.reader.BasicScanlineProcessor;
|
||||
|
||||
/**
|
||||
* Base implementation that transforms scanlines of source pixels into scanlines of
|
||||
* ARGB8888 (32-bit integer) pixels in a destination Argb8888Bitmap object.
|
||||
* <p>
|
||||
* Note: I wonder if a better name is ScanlineConverter or PixelTransformer.
|
||||
*
|
||||
* @see Argb8888Processors
|
||||
*/
|
||||
public abstract class Argb8888ScanlineProcessor extends BasicScanlineProcessor {
|
||||
protected Argb8888Bitmap bitmap;
|
||||
protected Argb8888Palette palette;
|
||||
protected int y = 0;
|
||||
|
||||
// public Argb8888ScanlineProcessor(PngHeader header) {
|
||||
// this(header.bytesPerRow, new Argb8888Bitmap(header.width, header.height));
|
||||
// }
|
||||
|
||||
public Argb8888ScanlineProcessor(int bytesPerScanline, Argb8888Bitmap bitmap) {
|
||||
super(bytesPerScanline);
|
||||
this.bitmap = bitmap;
|
||||
}
|
||||
|
||||
// public Argb8888ScanlineProcessor(PngHeader header, PngScanlineBuffer scanlineReader, PngFrameControl currentFrame) {
|
||||
// super(header, scanlineReader, currentFrame);
|
||||
// bitmap = new Argb8888Bitmap(this.header.width, this.header.height);
|
||||
// }
|
||||
|
||||
public Argb8888Bitmap getBitmap() {
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
public Argb8888Palette getPalette() {
|
||||
return palette;
|
||||
}
|
||||
|
||||
public void setPalette(Argb8888Palette palette) {
|
||||
this.palette = palette;
|
||||
}
|
||||
|
||||
/**
|
||||
* When processing a sequence of frames a caller may want to write to a completely
|
||||
* new bitmap for every frame or re-use the bytes in an existing bitmap.
|
||||
*
|
||||
* @param header of image to use as basis for calculating image size.
|
||||
* @return processor that will write to a new bitmap.
|
||||
*/
|
||||
public Argb8888ScanlineProcessor cloneWithNewBitmap(PngHeader header) {
|
||||
Argb8888ScanlineProcessor cloned = clone(header.bytesPerRow, new Argb8888Bitmap(header.width, header.height));
|
||||
if (this.palette != null) {
|
||||
cloned.setPalette(new Argb8888Palette(palette.argbArray));
|
||||
}
|
||||
return cloned;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* When processing a sequence of frames a caller may want to write to a completely
|
||||
* new bitmap for every frame or re-use the bytes in an existing bitmap.
|
||||
*
|
||||
* @param header of image to use as basis for calculating image size.
|
||||
* @return processor that will write to the same bitmap as the current processor.
|
||||
*/
|
||||
public Argb8888ScanlineProcessor cloneWithSharedBitmap(PngHeader header) {
|
||||
Argb8888ScanlineProcessor cloned = clone(header.bytesPerRow, bitmap.makeView(header.width, header.height));
|
||||
if (this.palette != null) {
|
||||
cloned.setPalette(new Argb8888Palette(palette.argbArray));
|
||||
}
|
||||
return cloned;
|
||||
|
||||
}
|
||||
|
||||
// public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
|
||||
// throw new IllegalStateException("TODO: override");
|
||||
// }
|
||||
abstract Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap);
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
// Copy from https://github.com/aellerton/japng
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package org.jackhuang.hmcl.ui.image.apng.argb8888;
|
||||
|
||||
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
|
||||
import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
|
||||
|
||||
/**
|
||||
* Common functionality for Argb8888Director implementations.
|
||||
*/
|
||||
public abstract class BasicArgb8888Director<ResultT> implements Argb8888Director<ResultT> {
|
||||
protected Argb8888ScanlineProcessor scanlineProcessor;
|
||||
|
||||
@Override
|
||||
public void receivePalette(Argb8888Palette palette) {
|
||||
scanlineProcessor.setPalette(palette);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void processTransparentPalette(byte[] bytes, int position, int length) throws PngException {
|
||||
Argb8888Palette palette = scanlineProcessor.getPalette();
|
||||
if (null == palette) {
|
||||
throw new PngIntegrityException("Received tRNS data but no palette is in place");
|
||||
}
|
||||
if (length <= 0 || length > palette.size()) {
|
||||
throw new PngIntegrityException(String.format("Received tRNS data length is invalid. Should be >1 && < %d but is %d", palette.size(), length));
|
||||
}
|
||||
for (int i = 0; i < length; i++) {
|
||||
final int alpha = 0xff & bytes[position + i];
|
||||
palette.argbArray[i] = alpha << 24 | palette.argbArray[i] & 0x00FFFFFF;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void processTransparentGreyscale(byte k1, byte k0) throws PngException {
|
||||
scanlineProcessor.processTransparentGreyscale(k1, k0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void processTransparentTruecolour(byte r1, byte r0, byte g1, byte g0, byte b1, byte b0) throws PngException {
|
||||
scanlineProcessor.processTransparentTruecolour(r1, r0, g1, g0, b1, b0);
|
||||
}
|
||||
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
// Copy from https://github.com/aellerton/japng
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package org.jackhuang.hmcl.ui.image.apng.argb8888;
|
||||
|
||||
import org.jackhuang.hmcl.ui.image.apng.PngScanlineBuffer;
|
||||
import org.jackhuang.hmcl.ui.image.apng.chunks.PngAnimationControl;
|
||||
import org.jackhuang.hmcl.ui.image.apng.chunks.PngFrameControl;
|
||||
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
|
||||
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
|
||||
|
||||
/**
|
||||
* This will build a single bitmap: the default image within the PNG file.
|
||||
* Any animation data (or any other data) will not be processed.
|
||||
*/
|
||||
public class DefaultImageArgb8888Director extends BasicArgb8888Director<Argb8888Bitmap> {
|
||||
|
||||
protected Argb8888Bitmap defaultImage;
|
||||
|
||||
@Override
|
||||
public void receiveHeader(PngHeader header, PngScanlineBuffer buffer) throws PngException {
|
||||
defaultImage = new Argb8888Bitmap(header.width, header.height);
|
||||
scanlineProcessor = Argb8888Processors.from(header, buffer, defaultImage);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean wantDefaultImage() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean wantAnimationFrames() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Argb8888ScanlineProcessor beforeDefaultImage() {
|
||||
return scanlineProcessor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void receiveDefaultImage(Argb8888Bitmap bitmap) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void receiveAnimationControl(PngAnimationControl control) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public Argb8888ScanlineProcessor receiveFrameControl(PngFrameControl control) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void receiveFrameImage(Argb8888Bitmap bitmap) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public Argb8888Bitmap getResult() {
|
||||
return defaultImage;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copy from https://github.com/aellerton/japng
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package org.jackhuang.hmcl.ui.image.apng.chunks;
|
||||
|
||||
/**
|
||||
* A PngAnimationControl object contains data parsed from the ``acTL``
|
||||
* animation control chunk of an animated PNG file.
|
||||
*/
|
||||
public class PngAnimationControl {
|
||||
public final int numFrames;
|
||||
public final int numPlays;
|
||||
|
||||
public PngAnimationControl(int numFrames, int numPlays) {
|
||||
this.numFrames = numFrames;
|
||||
this.numPlays = numPlays;
|
||||
}
|
||||
|
||||
public boolean loopForever() {
|
||||
return 0 == numPlays;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "PngAnimationControl{" +
|
||||
"numFrames=" + numFrames +
|
||||
", numPlays=" + numPlays +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// Copy from https://github.com/aellerton/japng
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package org.jackhuang.hmcl.ui.image.apng.chunks;
|
||||
|
||||
import org.jackhuang.hmcl.ui.image.apng.map.PngChunkMap;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A PngFrameControl object contains data parsed from the ``fcTL`` chunk data
|
||||
* in an animated PNG File.
|
||||
* <p>
|
||||
* See https://wiki.mozilla.org/APNG_Specification#.60fcTL.60:_The_Frame_Control_Chunk
|
||||
* <pre>
|
||||
* 0 sequence_number (unsigned int) Sequence number of the animation chunk, starting from 0
|
||||
* 4 width (unsigned int) Width of the following frame
|
||||
* 8 height (unsigned int) Height of the following frame
|
||||
* 12 x_offset (unsigned int) X position at which to render the following frame
|
||||
* 16 y_offset (unsigned int) Y position at which to render the following frame
|
||||
* 20 delay_num (unsigned short) Frame delay fraction numerator
|
||||
* 22 delay_den (unsigned short) Frame delay fraction denominator
|
||||
* 24 dispose_op (byte) Type of frame area disposal to be done after rendering this frame
|
||||
* 25 blend_op (byte) Type of frame area rendering for this frame
|
||||
* </pre>
|
||||
* <p>
|
||||
* Delay denominator: from spec, "if denominator is zero it should be treated as 100ths of second".
|
||||
* <pre>
|
||||
* dispose op:
|
||||
* value
|
||||
* 0 APNG_DISPOSE_OP_NONE
|
||||
* 1 APNG_DISPOSE_OP_BACKGROUND
|
||||
* 2 APNG_DISPOSE_OP_PREVIOUS
|
||||
*
|
||||
* blend op:
|
||||
* value
|
||||
* 0 APNG_BLEND_OP_SOURCE
|
||||
* 1 APNG_BLEND_OP_OVER
|
||||
* </pre>
|
||||
*/
|
||||
public class PngFrameControl {
|
||||
public final int sequenceNumber;
|
||||
public final int width;
|
||||
public final int height;
|
||||
public final int xOffset;
|
||||
public final int yOffset;
|
||||
public final short delayNumerator;
|
||||
public final short delayDenominator;
|
||||
public final byte disposeOp;
|
||||
public final byte blendOp;
|
||||
List<PngChunkMap> imageChunks = new ArrayList<>(1); // TODO: this may be removed
|
||||
|
||||
public PngFrameControl(int sequenceNumber, int width, int height, int xOffset, int yOffset, short delayNumerator, short delayDenominator, byte disposeOp, byte blendOp) {
|
||||
this.sequenceNumber = sequenceNumber;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.xOffset = xOffset;
|
||||
this.yOffset = yOffset;
|
||||
this.delayNumerator = delayNumerator;
|
||||
this.delayDenominator = delayDenominator == 0 ? 100 : delayDenominator; // APNG spec says zero === 100.
|
||||
this.disposeOp = disposeOp;
|
||||
this.blendOp = blendOp;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return number of milliseconds to show this frame for
|
||||
*/
|
||||
public int getDelayMilliseconds() {
|
||||
if (delayDenominator == 1000) {
|
||||
return delayNumerator;
|
||||
} else {
|
||||
// if denom is 100 then need to multiple by 10
|
||||
float f = 1000 / delayDenominator; // 1000/100 -> 10
|
||||
return (int) (delayNumerator * f);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: can this be removed?
|
||||
public void appendImageData(PngChunkMap chunkMap) {
|
||||
imageChunks.add(chunkMap);
|
||||
}
|
||||
|
||||
// TODO: this may be removed
|
||||
public List<PngChunkMap> getImageChunks() {
|
||||
return imageChunks;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "PngFrameControl{" +
|
||||
"sequenceNumber=" + sequenceNumber +
|
||||
", width=" + width +
|
||||
", height=" + height +
|
||||
", xOffset=" + xOffset +
|
||||
", yOffset=" + yOffset +
|
||||
", delayNumerator=" + delayNumerator +
|
||||
", delayDenominator=" + delayDenominator +
|
||||
", disposeOp=" + disposeOp +
|
||||
", blendOp=" + blendOp +
|
||||
'}';
|
||||
}
|
||||
|
||||
// mainly for ease in unit testing
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
|
||||
PngFrameControl that = (PngFrameControl) o;
|
||||
|
||||
if (sequenceNumber != that.sequenceNumber) return false;
|
||||
if (width != that.width) return false;
|
||||
if (height != that.height) return false;
|
||||
if (xOffset != that.xOffset) return false;
|
||||
if (yOffset != that.yOffset) return false;
|
||||
if (delayNumerator != that.delayNumerator) return false;
|
||||
if (delayDenominator != that.delayDenominator) return false;
|
||||
if (disposeOp != that.disposeOp) return false;
|
||||
return blendOp == that.blendOp;
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int result = sequenceNumber;
|
||||
result = 31 * result + width;
|
||||
result = 31 * result + height;
|
||||
result = 31 * result + xOffset;
|
||||
result = 31 * result + yOffset;
|
||||
result = 31 * result + (int) delayNumerator;
|
||||
result = 31 * result + (int) delayDenominator;
|
||||
result = 31 * result + (int) disposeOp;
|
||||
result = 31 * result + (int) blendOp;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Copy from https://github.com/aellerton/japng
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package org.jackhuang.hmcl.ui.image.apng.chunks;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* A PngGamma object represents data parsed from a ``gAMA`` chunk.
|
||||
*/
|
||||
public class PngGamma {
|
||||
public final int imageGamma;
|
||||
|
||||
public PngGamma(int imageGamma) {
|
||||
this.imageGamma = imageGamma;
|
||||
}
|
||||
|
||||
public static PngGamma from(DataInputStream dis) throws IOException {
|
||||
return new PngGamma(dis.readInt());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
// Copy from https://github.com/aellerton/japng
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package org.jackhuang.hmcl.ui.image.apng.chunks;
|
||||
|
||||
import org.jackhuang.hmcl.ui.image.apng.PngColourType;
|
||||
import org.jackhuang.hmcl.ui.image.apng.reader.PngReadHelper;
|
||||
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
|
||||
import org.jackhuang.hmcl.ui.image.apng.error.PngFeatureException;
|
||||
import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
|
||||
|
||||
import java.io.DataInput;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Created by aellerton on 10/05/2015.
|
||||
*/
|
||||
public class PngHeader {
|
||||
/**
|
||||
* Number of pixels (columns) wide.
|
||||
*/
|
||||
public final int width;
|
||||
|
||||
/**
|
||||
* Number of pixels (rows) high.
|
||||
*/
|
||||
public final int height;
|
||||
|
||||
/**
|
||||
* The bitDepth is the number of bits for each <em>channel</em> of a given pixel.
|
||||
* A better name might be "bitsPerPixelChannel" but the name "bitDepth" is used
|
||||
* throughout the PNG specification.
|
||||
* <p>
|
||||
* A truecolour image with a bitDepth of 8 means that the red channel of a pixel
|
||||
* has 8 bits (so 256 levels of red), green has 8 bits (256 levels of green), and
|
||||
* blue has 8 bits (so 256 levels of green). That means the total bitsPerPixel
|
||||
* for that bitmap will be 8+8+8 = 24.
|
||||
* <p>
|
||||
* A truecolour <em>with alpha</em> image with bitDepth of 8 will be the same
|
||||
* except every alpha element of every pixel will have 8 bits (so 256 levels of
|
||||
* alpha transparency), meaning that the total bitsPerPixel for that bitmap will
|
||||
* be 8+8+8+8=32.
|
||||
* <p>
|
||||
* A truecolour with alpha image with <em>bitDepth of 16</em> means that each of
|
||||
* red, green blue and alpha have 16-bits respectively, meaning that the total
|
||||
* bitsPerPixel will be 16+16+16+16 = 64.
|
||||
* <p>
|
||||
* A greyscale image (no alpha) with bitDepth of 16 has only a grey channel for
|
||||
* each pixel, so the bitsPerPixel will also be 16.
|
||||
* <p>
|
||||
* But a greyscale image <em>with alpha</em> with a bitDepth of 16 has a grey
|
||||
* channel and an alpha channel, each with 16 bits so the bitsPerPixel will be
|
||||
* 16+16=32.
|
||||
* <p>
|
||||
* As for palette-based images...
|
||||
* <ul>
|
||||
* <li>A monochrome image or image with 2 colour palette has bitDepth=1.</li>
|
||||
* <li>An image with 4 colour palette has bitDepth=2.</li>
|
||||
* <li>An image with 8 colour palette has bitDepth=3.</li>
|
||||
* <li>An image with 16 colour palette has bitDepth=4.</li>
|
||||
* <li>A greyscale image with 16 levels of gray <em>and an alpha channel</em>
|
||||
* has bitDepth=4 and bitsPerPixel=8 because the gray and the alpha channel
|
||||
* each have 4 bits.</li>
|
||||
* </ul>
|
||||
*
|
||||
* @see #bitsPerPixel
|
||||
*/
|
||||
public final byte bitDepth;
|
||||
|
||||
/**
|
||||
* Every PNG image must be exactly one of the standard types as defined by the
|
||||
* PNG specification. Better names might have been "imageType" or "imageFormat"
|
||||
* but the name "colourType" is used throughout the PNG spec.
|
||||
*/
|
||||
public final PngColourType colourType;
|
||||
|
||||
/**
|
||||
* Compression type of the file.
|
||||
* In practice this is redundant: it may be zip and nothing else.
|
||||
*/
|
||||
public final byte compressionMethod;
|
||||
|
||||
/**
|
||||
* Filter method used by the file.
|
||||
* In practice this is redundant because the filter types are set in the
|
||||
* specification and have never been (and never will be) extended.
|
||||
*/
|
||||
public final byte filterMethod;
|
||||
|
||||
/**
|
||||
* An image is either interlaced or not interlaced.
|
||||
* At the time of writing only non-interlaced is supported by this library.
|
||||
*/
|
||||
public final byte interlaceMethod;
|
||||
|
||||
/**
|
||||
* The number of bits that comprise a single pixel in this bitmap (or every
|
||||
* frame if animated). This is distinct from bitDepth.
|
||||
*
|
||||
* @see #bitDepth
|
||||
*/
|
||||
public final int bitsPerPixel;
|
||||
public final int bytesPerRow;
|
||||
public final int filterOffset;
|
||||
|
||||
public PngHeader(int width, int height, byte bitDepth, PngColourType colourType) {
|
||||
this(width, height, bitDepth, colourType, (byte) 0, (byte) 0, (byte) 0);
|
||||
}
|
||||
|
||||
public PngHeader(int width, int height, byte bitDepth, PngColourType colourType, byte compressionMethod, byte filterMethod, byte interlaceMethod) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.bitDepth = bitDepth;
|
||||
this.colourType = colourType;
|
||||
this.compressionMethod = compressionMethod;
|
||||
this.filterMethod = filterMethod;
|
||||
this.interlaceMethod = interlaceMethod;
|
||||
this.bitsPerPixel = bitDepth * colourType.componentsPerPixel;
|
||||
this.bytesPerRow = PngReadHelper.calculateBytesPerRow(width, bitDepth, colourType, interlaceMethod);
|
||||
//this.filterOffset = this.bitsPerPixel < 8 ? 1 : this.bitsPerPixel>>3; // minimum of 1 byte. RGB888 will be 3 bytes. RGBAFFFF is 8 bytes.
|
||||
this.filterOffset = (this.bitsPerPixel + 7) >> 3; // libpng
|
||||
|
||||
// from pypng
|
||||
// # Derived values
|
||||
// # http://www.w3.org/TR/PNG/#6Colour-values
|
||||
// colormap = bool(self.color_type & 1)
|
||||
// greyscale = not (self.color_type & 2)
|
||||
// alpha = bool(self.color_type & 4)
|
||||
// color_planes = (3,1)[greyscale or colormap]
|
||||
// planes = color_planes + alpha
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "PngHeader{" +
|
||||
"width=" + width +
|
||||
", height=" + height +
|
||||
", bitDepth=" + bitDepth +
|
||||
", colourType=" + colourType +
|
||||
", compressionMethod=" + compressionMethod +
|
||||
", filterMethod=" + filterMethod +
|
||||
", interlaceMethod=" + interlaceMethod +
|
||||
'}';
|
||||
}
|
||||
|
||||
public boolean isInterlaced() {
|
||||
return interlaceMethod == 1;
|
||||
}
|
||||
|
||||
public boolean isZipCompression() {
|
||||
return compressionMethod == 0;
|
||||
}
|
||||
|
||||
public boolean isGreyscale() {
|
||||
return colourType == PngColourType.PNG_GREYSCALE | colourType == PngColourType.PNG_GREYSCALE_WITH_ALPHA;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true if the image type indicates there is an alpha value for every pixel.
|
||||
* Note that this take into account any transparency or background chunk.
|
||||
*/
|
||||
public boolean hasAlphaChannel() {
|
||||
return colourType == PngColourType.PNG_GREYSCALE_WITH_ALPHA | colourType == PngColourType.PNG_TRUECOLOUR_WITH_ALPHA;
|
||||
}
|
||||
|
||||
public static PngHeader makeTruecolour(int width, int height) {
|
||||
return new PngHeader(width, height, (byte) 8, PngColourType.PNG_TRUECOLOUR);
|
||||
}
|
||||
|
||||
public static PngHeader makeTruecolourAlpha(int width, int height) {
|
||||
return new PngHeader(width, height, (byte) 8, PngColourType.PNG_TRUECOLOUR_WITH_ALPHA);
|
||||
}
|
||||
|
||||
public PngHeader adjustFor(PngFrameControl frame) {
|
||||
if (frame == null) {
|
||||
return this;
|
||||
} else {
|
||||
return new PngHeader(frame.width, frame.height, this.bitDepth, this.colourType, this.compressionMethod, this.filterMethod, this.interlaceMethod);
|
||||
}
|
||||
}
|
||||
|
||||
public static void checkHeaderParameters(int width, int height, byte bitDepth, PngColourType colourType, byte compressionMethod, byte filterMethod, byte interlaceMethod) throws PngException {
|
||||
|
||||
switch (bitDepth) {
|
||||
case 1:
|
||||
case 2:
|
||||
case 4:
|
||||
case 8:
|
||||
case 16:
|
||||
break; // all fine
|
||||
default:
|
||||
throw new PngIntegrityException("Invalid bit depth " + bitDepth);
|
||||
}
|
||||
|
||||
// thanks to pypng
|
||||
if (colourType.isIndexed() && bitDepth > 8) {
|
||||
throw new PngIntegrityException(String.format(
|
||||
"Indexed images (colour type %d) cannot have bitdepth > 8 (bit depth %d)." +
|
||||
" See http://www.w3.org/TR/2003/REC-PNG-20031110/#table111 .", colourType.code, bitDepth));
|
||||
}
|
||||
|
||||
if (bitDepth < 8 && !colourType.supportsSubByteDepth()) {
|
||||
throw new PngIntegrityException(String.format(
|
||||
"Illegal combination of bit depth (%d) and colour type (%d)." +
|
||||
" See http://www.w3.org/TR/2003/REC-PNG-20031110/#table111 .", colourType.code, bitDepth));
|
||||
}
|
||||
|
||||
if (interlaceMethod != 0) {
|
||||
throw new PngFeatureException("Interlaced images are not yet supported");
|
||||
}
|
||||
}
|
||||
|
||||
public static PngHeader from(DataInput dis) throws IOException, PngException {
|
||||
int width = dis.readInt();
|
||||
int height = dis.readInt();
|
||||
byte bitDepth = dis.readByte();
|
||||
PngColourType colourType = PngColourType.fromByte(dis.readByte());
|
||||
byte compressionMethod = dis.readByte();
|
||||
byte filterMethod = dis.readByte();
|
||||
byte interlaceMethod = dis.readByte();
|
||||
checkHeaderParameters(width, height, bitDepth, colourType, compressionMethod, filterMethod, interlaceMethod);
|
||||
return new PngHeader(width, height, bitDepth, colourType, compressionMethod, filterMethod, interlaceMethod);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copy from https://github.com/aellerton/japng
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package org.jackhuang.hmcl.ui.image.apng.chunks;
|
||||
|
||||
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
|
||||
import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
|
||||
import org.jackhuang.hmcl.ui.image.apng.argb8888.Argb8888Palette;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* A PngPalette object represents an ordered array of RGB (888) colour tuples
|
||||
* derived from a PLTE chunk.
|
||||
* <p>
|
||||
* WARNING: this class may not remain in the API.
|
||||
* When implementing the Argb8888 decoders it seems clear that every output
|
||||
* format will benefit from a specific palette implementation, so this attempt
|
||||
* at a generic palette may be removed.
|
||||
*
|
||||
* @see Argb8888Palette
|
||||
*/
|
||||
public class PngPalette {
|
||||
// TODO: should include alpha here? Can then store as int32s?
|
||||
public final byte[] rgb888;
|
||||
public final int[] rgba8888; // Including this duplicate for now. Not sure if will keep it.
|
||||
public final int numColours;
|
||||
|
||||
public static final int LENGTH_RGB_BYTES = 3;
|
||||
public static final int BYTE_INITIAL_ALPHA = 0xff;
|
||||
|
||||
public PngPalette(byte[] rgb888, int[] rgba8888) {
|
||||
this.rgb888 = rgb888;
|
||||
this.rgba8888 = rgba8888;
|
||||
this.numColours = rgb888.length / 3;
|
||||
}
|
||||
|
||||
public static PngPalette from(byte[] source, int first, int length) throws PngException {
|
||||
if (length % LENGTH_RGB_BYTES != 0) {
|
||||
throw new PngIntegrityException(String.format("Invalid palette data length: %d (not a multiple of 3)", length));
|
||||
}
|
||||
|
||||
return new PngPalette(
|
||||
Arrays.copyOfRange(source, first, first + length),
|
||||
rgba8888From(source, first, length)
|
||||
);
|
||||
}
|
||||
|
||||
private static int[] rgba8888From(byte[] source, int first, int length) {
|
||||
int last = first + length;
|
||||
int numColours = length / 3;
|
||||
int[] rgba8888 = new int[numColours];
|
||||
int j = 0;
|
||||
for (int i = first; i < last; i += LENGTH_RGB_BYTES) {
|
||||
rgba8888[j] = source[i] << 24 | source[i + 1] << 16 | source[i + 2] << 8 | BYTE_INITIAL_ALPHA;
|
||||
j++;
|
||||
}
|
||||
return rgba8888;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copy from https://github.com/aellerton/japng
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package org.jackhuang.hmcl.ui.image.apng.error;
|
||||
|
||||
/**
|
||||
* All exceptions in the library are a PngException or subclass of it.
|
||||
*/
|
||||
public class PngException extends Exception {
|
||||
int code;
|
||||
|
||||
public PngException(int code, String message) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public PngException(int code, String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Copy from https://github.com/aellerton/japng
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package org.jackhuang.hmcl.ui.image.apng.error;
|
||||
|
||||
import org.jackhuang.hmcl.ui.image.apng.PngConstants;
|
||||
|
||||
/**
|
||||
* A PngFeatureException is thrown when a feature used in a specific file
|
||||
* is not supported by the pipeline being used. For example, at the time
|
||||
* of writing, interlaced image reading is not supported.
|
||||
*/
|
||||
public class PngFeatureException extends PngException {
|
||||
public PngFeatureException(String message) {
|
||||
super(PngConstants.ERROR_FEATURE_NOT_SUPPORTED, message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Copy from https://github.com/aellerton/japng
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package org.jackhuang.hmcl.ui.image.apng.error;
|
||||
|
||||
import org.jackhuang.hmcl.ui.image.apng.PngConstants;
|
||||
|
||||
/**
|
||||
* A PngIntegrityException is thrown when some aspect of the PNG file being
|
||||
* loaded is invalid or unacceptable according to the PNG Specification.
|
||||
* <p>
|
||||
* For example, requesting a 16-bit palette image is invalid, or a colour type
|
||||
* outside of the allowed set.
|
||||
*/
|
||||
public class PngIntegrityException extends PngException {
|
||||
public PngIntegrityException(String message) {
|
||||
super(PngConstants.ERROR_INTEGRITY, message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copy from https://github.com/aellerton/japng
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package org.jackhuang.hmcl.ui.image.apng.map;
|
||||
|
||||
import org.jackhuang.hmcl.ui.image.apng.PngChunkCode;
|
||||
|
||||
/**
|
||||
* A single chunk from a PNG file is represented by a PngChunkMap.
|
||||
* <p>
|
||||
* WARNING: not sure if this API will remain.
|
||||
* </p>
|
||||
*/
|
||||
public class PngChunkMap {
|
||||
/**
|
||||
* The code like IHDR, IPAL, IDAT. If the chunk code is non-standard,
|
||||
* the UNKNOWN code will be used and the codeString will be set.
|
||||
*/
|
||||
public PngChunkCode code;
|
||||
|
||||
/**
|
||||
* Number of bytes containing the data portion of the chunk. Note that this excludes
|
||||
* the last 4 bytes that are the CRC checksom.
|
||||
*/
|
||||
public int dataLength;
|
||||
|
||||
/**
|
||||
* Integer offset of the first byte of data in this chunk (the byte immediately
|
||||
* following the chunk length 32-bits and the chunk type 32-bits) from byte zero
|
||||
* of the source.
|
||||
*/
|
||||
public int dataPosition;
|
||||
|
||||
public int checksum;
|
||||
|
||||
public PngChunkMap(PngChunkCode code, int dataPosition, int dataLength, int checksum) {
|
||||
this.code = code;
|
||||
this.dataLength = dataLength;
|
||||
this.dataPosition = dataPosition;
|
||||
this.checksum = checksum;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "PngChunkMap{" +
|
||||
"letters=" + code +
|
||||
", dataLength=" + dataLength +
|
||||
", dataPosition=" + dataPosition +
|
||||
", checksum=" + checksum +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Copy from https://github.com/aellerton/japng
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package org.jackhuang.hmcl.ui.image.apng.map;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A PngMap represents a map over an entire single PNG file.
|
||||
* <p>
|
||||
* WARNING: not sure if this API will remain.
|
||||
* </p>
|
||||
*/
|
||||
public class PngMap {
|
||||
public String source;
|
||||
public List<PngChunkMap> chunks;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Copy from https://github.com/aellerton/japng
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package org.jackhuang.hmcl.ui.image.apng.map;
|
||||
|
||||
import org.jackhuang.hmcl.ui.image.apng.PngChunkCode;
|
||||
import org.jackhuang.hmcl.ui.image.apng.PngConstants;
|
||||
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
|
||||
import org.jackhuang.hmcl.ui.image.apng.reader.PngReader;
|
||||
import org.jackhuang.hmcl.ui.image.apng.reader.PngSource;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
|
||||
/**
|
||||
* Simple processor that skips all chunk content and ignores checksums, with
|
||||
* sole objective of building a map of the contents of a PNG file.
|
||||
* <p>
|
||||
* WARNING: not sure if this API will remain.
|
||||
* </p>
|
||||
*/
|
||||
public class PngMapReader implements PngReader<PngMap> {
|
||||
PngMap map;
|
||||
|
||||
public PngMapReader(String sourceName) {
|
||||
map = new PngMap();
|
||||
map.source = sourceName;
|
||||
map.chunks = new ArrayList<>(4);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean readChunk(PngSource source, int code, int dataLength) throws PngException, IOException {
|
||||
int dataPosition = source.tell();
|
||||
source.skip(dataLength);
|
||||
int chunkChecksum = source.readInt();
|
||||
map.chunks.add(new PngChunkMap(PngChunkCode.from(code), dataPosition, dataLength, chunkChecksum));
|
||||
|
||||
return code == PngConstants.IEND_VALUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void finishedChunks(PngSource source) throws PngException, IOException {
|
||||
// NOP
|
||||
}
|
||||
|
||||
@Override
|
||||
public PngMap getResult() {
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* @see <a href="https://github.com/aellerton/japng">japng</a>
|
||||
*/
|
||||
package org.jackhuang.hmcl.ui.image.apng;
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
// Copy from https://github.com/aellerton/japng
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package org.jackhuang.hmcl.ui.image.apng.reader;
|
||||
|
||||
import org.jackhuang.hmcl.ui.image.apng.util.PartialInflaterInputStream;
|
||||
|
||||
import java.io.FilterInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.util.zip.Inflater;
|
||||
|
||||
/**
|
||||
* A BasicScanlineProcessor manages a re-entrant java.util.zip.Inflater object and
|
||||
* basic bytesPerLine management.
|
||||
*/
|
||||
public abstract class BasicScanlineProcessor implements PngScanlineProcessor {
|
||||
protected final int bytesPerLine;
|
||||
protected Inflater inflater = new Inflater();
|
||||
//protected PngHeader header;
|
||||
//protected PngScanlineBuffer scanlineReader;
|
||||
//protected PngFrameControl currentFrame;
|
||||
|
||||
//public DefaultScanlineProcessor(PngHeader header, PngScanlineBuffer scanlineReader, PngFrameControl currentFrame) {
|
||||
public BasicScanlineProcessor(int bytesPerScanline) {
|
||||
// this.header = header.adjustFor(currentFrame);
|
||||
// this.currentFrame = currentFrame;
|
||||
// this.scanlineReader = scanlineReader;
|
||||
// this.bytesPerLine = this.header.bytesPerRow;
|
||||
this.bytesPerLine = bytesPerScanline;
|
||||
}
|
||||
|
||||
//@Override
|
||||
//abstract public void processScanline(byte[] bytes, int position);
|
||||
|
||||
@Override
|
||||
public FilterInputStream makeInflaterInputStream(InputStream inputStream) {
|
||||
return new PartialInflaterInputStream(inputStream, inflater);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getBytesPerLine() {
|
||||
return bytesPerLine;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isFinished() {
|
||||
return inflater.finished();
|
||||
}
|
||||
|
||||
// @Override
|
||||
// public InflaterInputStream connect(InputStream inputStream) {
|
||||
// multipartStream.add(inputStream);
|
||||
// return iis;
|
||||
// }
|
||||
|
||||
@Override
|
||||
public void processTransparentGreyscale(byte k1, byte k0) {
|
||||
// NOP by default
|
||||
}
|
||||
|
||||
@Override
|
||||
public void processTransparentTruecolour(byte r1, byte r0, byte g1, byte g0, byte b1, byte b0) {
|
||||
// NOP by default
|
||||
}
|
||||
}
|
||||
+303
@@ -0,0 +1,303 @@
|
||||
// Copy from https://github.com/aellerton/japng
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package org.jackhuang.hmcl.ui.image.apng.reader;
|
||||
|
||||
import org.jackhuang.hmcl.ui.image.apng.PngAnimationType;
|
||||
import org.jackhuang.hmcl.ui.image.apng.PngChunkCode;
|
||||
import org.jackhuang.hmcl.ui.image.apng.PngConstants;
|
||||
import org.jackhuang.hmcl.ui.image.apng.chunks.PngAnimationControl;
|
||||
import org.jackhuang.hmcl.ui.image.apng.chunks.PngFrameControl;
|
||||
import org.jackhuang.hmcl.ui.image.apng.chunks.PngGamma;
|
||||
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
|
||||
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
|
||||
import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
|
||||
import org.jackhuang.hmcl.ui.image.apng.map.PngChunkMap;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* The DefaultPngChunkReader is the default PNG chunk-reading workhorse.
|
||||
* <p>
|
||||
* Note that any chunk types not recognised can be processed in the readOtherChunk()
|
||||
* method.
|
||||
*/
|
||||
public class DefaultPngChunkReader<ResultT> implements PngChunkReader<ResultT> {
|
||||
protected PngChunkProcessor<ResultT> processor;
|
||||
protected boolean seenHeader = false;
|
||||
protected int idatCount = 0;
|
||||
protected int apngSequenceExpect = 0;
|
||||
protected PngAnimationType animationType = PngAnimationType.NOT_ANIMATED;
|
||||
//private PngMainImageOp mainImageOp = PngMainImageOp.MAIN_IMAGE_KEEP;
|
||||
|
||||
public DefaultPngChunkReader(PngChunkProcessor<ResultT> processor) {
|
||||
this.processor = processor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean readChunk(PngSource source, int code, int dataLength) throws PngException, IOException {
|
||||
int dataPosition = source.tell(); // note the start position before any further reads are done.
|
||||
|
||||
if (dataLength < 0) {
|
||||
throw new PngIntegrityException(String.format("Corrupted read (Data length %d)", dataLength));
|
||||
}
|
||||
|
||||
switch (code) {
|
||||
case PngConstants.IHDR_VALUE:
|
||||
readHeaderChunk(source, dataLength);
|
||||
break;
|
||||
|
||||
case PngConstants.IEND_VALUE:
|
||||
// NOP
|
||||
break;
|
||||
|
||||
case PngConstants.gAMA_VALUE:
|
||||
readGammaChunk(source, dataLength);
|
||||
break;
|
||||
|
||||
case PngConstants.bKGD_VALUE:
|
||||
readBackgroundChunk(source, dataLength);
|
||||
break;
|
||||
|
||||
case PngConstants.tRNS_VALUE:
|
||||
readTransparencyChunk(source, dataLength);
|
||||
break;
|
||||
|
||||
case PngConstants.PLTE_VALUE:
|
||||
readPaletteChunk(source, dataLength);
|
||||
break;
|
||||
|
||||
case PngConstants.IDAT_VALUE:
|
||||
readImageDataChunk(source, dataLength);
|
||||
break;
|
||||
|
||||
case PngConstants.acTL_VALUE:
|
||||
readAnimationControlChunk(source, dataLength);
|
||||
break;
|
||||
|
||||
case PngConstants.fcTL_VALUE:
|
||||
readFrameControlChunk(source, dataLength);
|
||||
break;
|
||||
|
||||
case PngConstants.fdAT_VALUE:
|
||||
readFrameImageDataChunk(source, dataLength);
|
||||
break;
|
||||
|
||||
default:
|
||||
readOtherChunk(code, source, dataPosition, dataLength);
|
||||
break;
|
||||
}
|
||||
|
||||
int chunkChecksum = source.readInt();
|
||||
processChunkEnd(code, dataPosition, dataLength, chunkChecksum);
|
||||
return code == PngConstants.IEND_VALUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void processChunkEnd(int code, int dataPosition, int dataLength, int chunkChecksum) throws PngException {
|
||||
processor.processChunkMapItem(new PngChunkMap(PngChunkCode.from(code), dataPosition, dataLength, chunkChecksum));
|
||||
//container.chunks.add(new PngChunkMap(PngChunkCode.from(code), dataLength, dataPosition, chunkChecksum));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void readHeaderChunk(PngSource source, int dataLength) throws IOException, PngException {
|
||||
PngHeader header = PngHeader.from(source.getDis());
|
||||
seenHeader = true;
|
||||
processor.processHeader(header); // Hmm. Prefer header = PngHeader.from(source) ?
|
||||
}
|
||||
|
||||
@Override
|
||||
public void readGammaChunk(PngSource source, int dataLength) throws PngException, IOException {
|
||||
processor.processGamma(PngGamma.from(source.getDis()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void readTransparencyChunk(PngSource source, int dataLength) throws IOException, PngException {
|
||||
// TODO
|
||||
//processor.processTransparency(PngTransparency.from(source, dataLength));
|
||||
//throw new PngFeatureException("TODO UP TO HERE");
|
||||
//source.skip(dataLength);
|
||||
|
||||
// if (dataLength % 3 != 0) {
|
||||
// throw new PngIntegrityException(String.format("png spec: palette chunk length must be divisible by 3: %d", dataLength));
|
||||
// }
|
||||
|
||||
if (source.supportsByteAccess()) {
|
||||
processor.processTransparency(source.getBytes(), source.tell(), dataLength);
|
||||
source.skip(dataLength);
|
||||
} else {
|
||||
byte[] paletteBytes = new byte[dataLength];
|
||||
//ByteStreams.readFully(source.getBis(), paletteBytes);
|
||||
source.getDis().readFully(paletteBytes);
|
||||
processor.processTransparency(paletteBytes, 0, dataLength);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void readBackgroundChunk(PngSource source, int dataLength) throws IOException, PngException {
|
||||
if (!seenHeader) {
|
||||
throw new PngIntegrityException("bKGD chunk received before IHDR chunk");
|
||||
}
|
||||
// TODO
|
||||
//processor.processBackground(PngBackground.from(source, dataLength);
|
||||
source.skip(dataLength);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void readPaletteChunk(PngSource source, int dataLength) throws IOException, PngException {
|
||||
|
||||
if (dataLength % 3 != 0) {
|
||||
throw new PngIntegrityException(String.format("png spec: palette chunk length must be divisible by 3: %d", dataLength));
|
||||
}
|
||||
// TODO: can check if colour type matches palette type, or if any palette received before (overkill?)
|
||||
|
||||
if (source.supportsByteAccess()) {
|
||||
processor.processPalette(source.getBytes(), source.tell(), dataLength);
|
||||
source.skip(dataLength);
|
||||
} else {
|
||||
byte[] paletteBytes = new byte[dataLength];
|
||||
//ByteStreams.readFully(source.getBis(), paletteBytes);
|
||||
source.getDis().readFully(paletteBytes);
|
||||
processor.processPalette(paletteBytes, 0, dataLength);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void readImageDataChunk(PngSource source, int dataLength) throws PngException, IOException {
|
||||
|
||||
if (idatCount == 0 && apngSequenceExpect == 0) {
|
||||
// Processing a plain PNG (non animated) IDAT chunk
|
||||
animationType = PngAnimationType.NOT_ANIMATED; // processor.chooseApngImageType(PngAnimationType.NOT_ANIMATED, null);
|
||||
}
|
||||
idatCount++;
|
||||
|
||||
switch (animationType) {
|
||||
case ANIMATED_DISCARD_DEFAULT_IMAGE:
|
||||
// do nothing
|
||||
source.skip(dataLength);
|
||||
break;
|
||||
case ANIMATED_KEEP_DEFAULT_IMAGE:
|
||||
processor.processFrameImageData(source.slice(dataLength), PngChunkCode.IDAT, source.tell(), dataLength);
|
||||
break;
|
||||
|
||||
case NOT_ANIMATED:
|
||||
default:
|
||||
processor.processDefaultImageData(source.slice(dataLength), PngChunkCode.IDAT, source.tell(), dataLength);
|
||||
break;
|
||||
}
|
||||
// source.skip(dataLength);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void readAnimationControlChunk(PngSource source, int dataLength) throws IOException, PngException {
|
||||
if (dataLength != PngConstants.LENGTH_acTL_CHUNK) {
|
||||
throw new PngIntegrityException(String.format("acTL chunk length must be %d, not %d", PngConstants.LENGTH_acTL_CHUNK, dataLength));
|
||||
}
|
||||
processor.processAnimationControl(new PngAnimationControl(source.readInt(), source.readInt()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void readFrameControlChunk(PngSource source, int dataLength) throws IOException, PngException {
|
||||
if (dataLength != PngConstants.LENGTH_fcTL_CHUNK) {
|
||||
throw new PngIntegrityException(String.format("fcTL chunk length must be %d, not %d", PngConstants.LENGTH_fcTL_CHUNK, dataLength));
|
||||
}
|
||||
int sequence = source.readInt(); // TODO: check sequence # is correct or PngIntegrityException
|
||||
|
||||
if (sequence != apngSequenceExpect) {
|
||||
throw new PngIntegrityException(String.format("fctl chunk expected sequence %d but received %d", apngSequenceExpect, sequence));
|
||||
}
|
||||
apngSequenceExpect++; // ready for next time
|
||||
|
||||
PngFrameControl frame = new PngFrameControl(
|
||||
sequence,
|
||||
source.readInt(), // width
|
||||
source.readInt(), // height
|
||||
source.readInt(), // x offset
|
||||
source.readInt(), // y offset
|
||||
source.readUnsignedShort(), // delay numerator
|
||||
source.readUnsignedShort(), // delay denominator
|
||||
source.readByte(), // dispose op
|
||||
source.readByte() // blend op
|
||||
);
|
||||
|
||||
if (sequence == 0) { // We're at the first frame...
|
||||
if (idatCount == 0) { // Not seen any IDAT chunks yet
|
||||
// APNG Spec says that when the first fcTL chunk is received *before* the first IDAT chunk
|
||||
// the main image of the PNG becomes the first frame in the animation.
|
||||
|
||||
animationType = PngAnimationType.ANIMATED_KEEP_DEFAULT_IMAGE; //processor.chooseApngImageType(PngAnimationType.ANIMATED_KEEP_DEFAULT_IMAGE, frame);
|
||||
|
||||
//mainImageOp = PngMainImageOp.MAIN_IMAGE_STARTS_ANIMATION;
|
||||
} else {
|
||||
//mainImageOp = PngMainImageOp.MAIN_IMAGE_DISCARD;
|
||||
animationType = PngAnimationType.ANIMATED_DISCARD_DEFAULT_IMAGE; // processor.chooseApngImageType(PngAnimationType.ANIMATED_DISCARD_DEFAULT_IMAGE, frame);
|
||||
}
|
||||
} else {
|
||||
// fall through
|
||||
}
|
||||
|
||||
processor.processFrameControl(frame);
|
||||
}
|
||||
|
||||
//public abstract void setMainImageOp(PngMainImageOp op);
|
||||
|
||||
@Override
|
||||
public void readFrameImageDataChunk(PngSource source, int dataLength) throws IOException, PngException {
|
||||
// Note that once the sequence number is confirmed as being correct that there
|
||||
// is no need to retain it in subsequent data.
|
||||
int position = source.tell();
|
||||
int sequence = source.readInt();
|
||||
dataLength -= 4; // After reading the sequence number the data is just like IDAT.
|
||||
|
||||
if (sequence != apngSequenceExpect) {
|
||||
throw new PngIntegrityException(String.format("fdAT chunk expected sequence %d but received %d", apngSequenceExpect, sequence));
|
||||
}
|
||||
apngSequenceExpect++; // for next time
|
||||
|
||||
//processFrameData(sequence, source, dataLength);
|
||||
//processFrameImageData(source, dataLength);
|
||||
processor.processFrameImageData(source.slice(dataLength), PngChunkCode.fdAT, source.tell(), dataLength);
|
||||
|
||||
// PngFrameControl current = container.getCurrentAnimationFrame();
|
||||
//
|
||||
// //imageDecoder
|
||||
// // TODO: send image bytes to digester
|
||||
// current.appendImageData(new PngChunkMap(PngChunkCode.fdAT, dataLength, position, 0));
|
||||
//
|
||||
// // TODO: skip everything except the frame sequence number
|
||||
|
||||
|
||||
// source.skip(dataLength);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void readOtherChunk(int code, PngSource source, int dataPosition, int dataLength) throws IOException {
|
||||
// If we're not processing it, got to skip it.
|
||||
source.skip(dataLength);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void finishedChunks(PngSource source) throws PngException, IOException {
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResultT getResult() {
|
||||
return processor.getResult();
|
||||
}
|
||||
|
||||
public boolean isSeenHeader() {
|
||||
return seenHeader;
|
||||
}
|
||||
|
||||
public int getIdatCount() {
|
||||
return idatCount;
|
||||
}
|
||||
|
||||
public int getApngSequenceExpect() {
|
||||
return apngSequenceExpect;
|
||||
}
|
||||
|
||||
public PngAnimationType getAnimationType() {
|
||||
return animationType;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Copy from https://github.com/aellerton/japng
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package org.jackhuang.hmcl.ui.image.apng.reader;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.DataInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
/**
|
||||
* A simple way to process a PNG file is to read the entire thing into memory.
|
||||
* <p>
|
||||
* Very bad for very large images (500Mb?)? Yes.
|
||||
* But simple for smaller (say <10Mb?) images? Also yes.
|
||||
* I'd certainly like to make a lean alternative that processes bytes without reading
|
||||
* the whole thing into memory, but it can come later. Patches welcome.
|
||||
* <p>
|
||||
* WARNING: I'm not sure I'll keep this. I may remove it and do everything with
|
||||
* a BufferedInputStream + DataInputStream.
|
||||
* </p>
|
||||
*/
|
||||
public class PngAtOnceSource implements PngSource {
|
||||
|
||||
final byte[] bytes;
|
||||
//private String sourceDescription;
|
||||
private final ByteArrayInputStream bis;
|
||||
private final DataInputStream dis;
|
||||
|
||||
public PngAtOnceSource(byte[] bytes) { //}, String sourceDescription) {
|
||||
this.bytes = bytes;
|
||||
//this.sourceDescription = sourceDescription;
|
||||
this.bis = new ByteArrayInputStream(this.bytes); // never closed because nothing to do for ByteArrayInputStream
|
||||
this.dis = new DataInputStream(this.bis); // never closed because underlying stream doesn't need to be closed.
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getLength() {
|
||||
return bytes == null ? 0 : bytes.length;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsByteAccess() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] getBytes() throws IOException {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte readByte() throws IOException {
|
||||
return dis.readByte();
|
||||
}
|
||||
|
||||
@Override
|
||||
public short readUnsignedShort() throws IOException {
|
||||
return (short) dis.readUnsignedShort();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int readInt() throws IOException {
|
||||
return dis.readInt();
|
||||
}
|
||||
|
||||
@Override
|
||||
public long skip(int chunkLength) throws IOException {
|
||||
return dis.skip(chunkLength);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int tell() {
|
||||
return bytes.length - bis.available();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int available() {
|
||||
return bis.available();
|
||||
}
|
||||
|
||||
// @Override
|
||||
// public String getSourceDescription() {
|
||||
// return sourceDescription;
|
||||
// }
|
||||
|
||||
// @Override
|
||||
// public ByteArrayInputStream getBis() {
|
||||
// return bis;
|
||||
// }
|
||||
|
||||
@Override
|
||||
public DataInputStream getDis() {
|
||||
return dis;
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream slice(int dataLength) throws IOException {
|
||||
// The below would be fine but in this case we have the full byte stream anyway...
|
||||
//return ByteStreams.limit(bis, dataLength);
|
||||
InputStream slice = new ByteArrayInputStream(bytes, tell(), dataLength);
|
||||
this.skip(dataLength);
|
||||
return slice;
|
||||
}
|
||||
|
||||
public static PngAtOnceSource from(InputStream is) throws IOException {
|
||||
return new PngAtOnceSource(is.readAllBytes());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// Copy from https://github.com/aellerton/japng
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package org.jackhuang.hmcl.ui.image.apng.reader;
|
||||
|
||||
import org.jackhuang.hmcl.ui.image.apng.PngChunkCode;
|
||||
import org.jackhuang.hmcl.ui.image.apng.chunks.PngAnimationControl;
|
||||
import org.jackhuang.hmcl.ui.image.apng.chunks.PngFrameControl;
|
||||
import org.jackhuang.hmcl.ui.image.apng.chunks.PngGamma;
|
||||
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
|
||||
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
|
||||
import org.jackhuang.hmcl.ui.image.apng.map.PngChunkMap;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
/**
|
||||
* While a PngChunkReader does the low-level reading of a PNG file, it delegates <em>processing</em> of
|
||||
* the data to a PngChunkProcessor instance. The class is generic about a specific result type because
|
||||
* the intention of a chunk processor is to yield some result, which might be a bitmap, a sequence of
|
||||
* bitmaps, a map of chunks, etc.
|
||||
*/
|
||||
public interface PngChunkProcessor<ResultT> {
|
||||
|
||||
/**
|
||||
* Subclasses can further process the header immediately after PngHeader has been read.
|
||||
* A subclass might elect to bail out of subsequent processing, e.g. if the image is too big
|
||||
* or has an invalid format.
|
||||
*
|
||||
* @param header of image
|
||||
*/
|
||||
void processHeader(PngHeader header) throws PngException;
|
||||
|
||||
/**
|
||||
* Process the PngGamma object parsed from a ``gaMA`` chunk.
|
||||
*
|
||||
* @param gamma parsed gamma data.
|
||||
* @throws PngException
|
||||
*/
|
||||
void processGamma(PngGamma gamma) throws PngException;
|
||||
|
||||
/**
|
||||
* Process the raw trNS data from the PNG file.
|
||||
* <p>
|
||||
* Note that this data is passed raw (but in a complete byte array).
|
||||
* It is not parsed into an object because it allows concrete implementations to define
|
||||
* their own way to handle the data. And it is not provided as in InputStream because
|
||||
* the length of the data is well defined based on the PNG colour type.
|
||||
*
|
||||
* @param bytes array to read data from.
|
||||
* @param position offset into the array to begin reading data from.
|
||||
* @param length number of bytes to read from the array.
|
||||
* @throws PngException
|
||||
*/
|
||||
void processTransparency(byte[] bytes, int position, int length) throws PngException;
|
||||
|
||||
/**
|
||||
* A PLTE chunk has been read and needs to be processed.
|
||||
* <p>
|
||||
* A byte array is provided to process. A pre-defined palette class is not loaded because
|
||||
* different rendering targets may elect to load the palette in different ways.
|
||||
* For example, rendering to an ARGB palette may load the RGB data and organise as
|
||||
* an array of 32-bit integer ARGB values.
|
||||
* </p>
|
||||
*
|
||||
* @param bytes representing the loaded palette data. Each colour is represented by three
|
||||
* bytes, 1 each for red, green and blue.
|
||||
* @param position that the colour values begin in bytes
|
||||
* @param length number of bytes that the palette bytes continue. Guaranteed to be a
|
||||
* multiple of 3.
|
||||
* @throws PngException
|
||||
*/
|
||||
void processPalette(byte[] bytes, int position, int length) throws PngException;
|
||||
|
||||
/**
|
||||
* Process one IDAT chunk the "default image" bitmap in a given file.
|
||||
* <p>
|
||||
* The "default", "main" or "primary image" of a file is what you'd generally
|
||||
* think of as "the" image in file, namely the image constructed of IDAT chunks
|
||||
* and displayed by any normal viewer or browser.
|
||||
* <p>
|
||||
* I theorise (without proof) that most PNG files in the wild have a single
|
||||
* IDAT chunk representing all pixels in the main image, but the PNG specification
|
||||
* requires loaders to handle the case where there are multiple IDAT chunks.
|
||||
* In the case of multiple chunks the data continues precisely where the last
|
||||
* IDAT chunk left off. The same applies to fdAT chunks.
|
||||
* <p>
|
||||
* The data is provided not in any pre-parsed object or even as a byte array because
|
||||
* the bytes can be arbitrarily long. The InputStream provided is a slice of the
|
||||
* containing source and will terminate at the end of the IDAT chunk.
|
||||
*
|
||||
* @param inputStream data source containing all bytes in the image data chunk.
|
||||
* @param code of the chunk which should always be IDAT in this case.
|
||||
* Compare to this.processFrameImageData.
|
||||
* @param position absolute position within the file. hmmm may be removed
|
||||
* @param length length of bytes of the hunk. hmmm may be removed
|
||||
* @throws IOException
|
||||
* @throws PngException
|
||||
*/
|
||||
void processDefaultImageData(InputStream inputStream, PngChunkCode code, int position, int length) throws IOException, PngException;
|
||||
|
||||
void processAnimationControl(PngAnimationControl animationControl) throws PngException;
|
||||
|
||||
void processFrameControl(PngFrameControl frameControl) throws PngException;
|
||||
|
||||
/**
|
||||
* The reader has determined that bitmap data needs to be processed for an animation frame.
|
||||
* The image data may be from an IDAT chunk or an fdAT chunk.
|
||||
* <p>
|
||||
* Whether the "default image" in a PNG file is to be part of the animation or discarded
|
||||
* is determed by the placement of the first fcTL chunk in the file. See the APNG specification.
|
||||
*
|
||||
* @param inputStream data source containing all bytes in the frame image data chunk.
|
||||
* @param code either IDAT or fdAT. Designed to allow an implementation to detect whether the
|
||||
* frame is from the "main image" or a subsequent image. May not be necessary.
|
||||
* @param position absolute position within the file. hmmm may be removed
|
||||
* @param length length of bytes of the hunk. hmmm may be removed
|
||||
* @throws IOException
|
||||
* @throws PngException
|
||||
*/
|
||||
void processFrameImageData(InputStream inputStream, PngChunkCode code, int position, int length) throws IOException, PngException;
|
||||
|
||||
/**
|
||||
* A subclass can record data about the chunk here.
|
||||
* <p>
|
||||
* WARNING: this API may be removed. I'm not sure if it is useful.
|
||||
*
|
||||
* @param chunkMapItem represents a "map" of a single chunk in the file.
|
||||
* @throws PngException
|
||||
*/
|
||||
void processChunkMapItem(PngChunkMap chunkMapItem) throws PngException;
|
||||
|
||||
/**
|
||||
* @return the result of processing all chunks. Only ever called after the file is fully read.
|
||||
*/
|
||||
ResultT getResult();
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// Copy from https://github.com/aellerton/japng
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package org.jackhuang.hmcl.ui.image.apng.reader;
|
||||
|
||||
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Created by aellerton on 15/05/2015.
|
||||
*/
|
||||
public interface PngChunkReader<ResultT> extends PngReader<ResultT> {
|
||||
@Override
|
||||
boolean readChunk(PngSource source, int code, int dataLength) throws PngException, IOException;
|
||||
|
||||
void processChunkEnd(int code, int dataPosition, int dataLength, int chunkChecksum) throws PngException;
|
||||
|
||||
void readHeaderChunk(PngSource source, int dataLength) throws IOException, PngException;
|
||||
|
||||
void readGammaChunk(PngSource source, int dataLength) throws PngException, IOException;
|
||||
|
||||
/**
|
||||
* Process the tRNS chunk.
|
||||
* <p>
|
||||
* From http://www.w3.org/TR/PNG/#11tRNS:
|
||||
* <p>
|
||||
* The tRNS chunk specifies either alpha values that are associated with palette entries (for indexed-colour
|
||||
* images) or a single transparent colour (for greyscale and truecolour images). The tRNS chunk contains:
|
||||
* <p>
|
||||
* Colour type 0:
|
||||
* <p>
|
||||
* Grey sample value 2 bytes
|
||||
* <p>
|
||||
* Colour type 2:
|
||||
* <p>
|
||||
* Red sample value 2 bytes
|
||||
* Blue sample value 2 bytes
|
||||
* Green sample value 2 bytes
|
||||
* <p>
|
||||
* Colour type 3:
|
||||
* <p>
|
||||
* Alpha for palette index 0 1 byte
|
||||
* Alpha for palette index 1 1 byte
|
||||
* ...etc... 1 byte
|
||||
* <p>
|
||||
* Note that for palette colour types the number of transparency entries may be less than the number of
|
||||
* entries in the palette. In that case all missing entries are assumed to be fully opaque.
|
||||
*
|
||||
* @param source
|
||||
* @param dataLength
|
||||
*/
|
||||
void readTransparencyChunk(PngSource source, int dataLength) throws IOException, PngException;
|
||||
|
||||
/**
|
||||
* Process the bKGD chunk.
|
||||
* <p>
|
||||
* From http://www.w3.org/TR/PNG/#11bKGD:
|
||||
* <p>
|
||||
* The bKGD chunk specifies a default background colour to present the image against.
|
||||
* If there is any other preferred background, either user-specified or part of a larger
|
||||
* page (as in a browser), the bKGD chunk should be ignored. The bKGD chunk contains:
|
||||
* <p>
|
||||
* Colour types 0 and 4
|
||||
* Greyscale 2 bytes
|
||||
* Colour types 2 and 6
|
||||
* Red 2 bytes
|
||||
* Green 2 bytes
|
||||
* Blue 2 bytes
|
||||
* Colour type 3
|
||||
* Palette index 1 byte
|
||||
* <p>
|
||||
* For colour type 3 (indexed-colour), the value is the palette index of the colour to be used as background.
|
||||
* <p>
|
||||
* For colour types 0 and 4 (greyscale, greyscale with alpha), the value is the grey level to be used as
|
||||
* background in the range 0 to (2bitdepth)-1. For colour types 2 and 6 (truecolour, truecolour with alpha),
|
||||
* the values are the colour to be used as background, given as RGB samples in the range 0 to (2bitdepth)-1.
|
||||
* In each case, for consistency, two bytes per sample are used regardless of the image bit depth. If the image
|
||||
* bit depth is less than 16, the least significant bits are used and the others are 0.
|
||||
*
|
||||
* @param source
|
||||
* @param dataLength
|
||||
* @throws IOException
|
||||
*/
|
||||
void readBackgroundChunk(PngSource source, int dataLength) throws IOException, PngException;
|
||||
|
||||
void readPaletteChunk(PngSource source, int dataLength) throws IOException, PngException;
|
||||
|
||||
/**
|
||||
* Read the IDAT chunk.
|
||||
* <p>
|
||||
* The default implementation skips the data, deferring to the finishedChunks() method
|
||||
* to process the data. Key reasons to do this:
|
||||
* <ul>
|
||||
* <li>There could be multiple IDAT chunks and the streams need to
|
||||
* be concatenated. This could be done in a single pass but it's more
|
||||
* complicated and is not the objective of this implementation.</li>
|
||||
* <li>This might be an APNG file and the IDAT chunk(s) are to be skipped.</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param source to read from
|
||||
* @param dataLength in bytes of the data
|
||||
* @throws PngException
|
||||
* @throws IOException
|
||||
*/
|
||||
void readImageDataChunk(PngSource source, int dataLength) throws PngException, IOException;
|
||||
|
||||
void readAnimationControlChunk(PngSource source, int dataLength) throws IOException, PngException;
|
||||
|
||||
/**
|
||||
* Read the fcTL chunk.
|
||||
* <p>
|
||||
* See https://wiki.mozilla.org/APNG_Specification#.60fcTL.60:_The_Frame_Control_Chunk
|
||||
*
|
||||
* @param source
|
||||
* @param dataLength
|
||||
* @throws IOException
|
||||
* @throws PngException
|
||||
*/
|
||||
void readFrameControlChunk(PngSource source, int dataLength) throws IOException, PngException;
|
||||
|
||||
void readFrameImageDataChunk(PngSource source, int dataLength) throws IOException, PngException;
|
||||
|
||||
/**
|
||||
* Give subclasses the opportunity to process a chunk code that was not recognised.
|
||||
*
|
||||
* @param code chunk type as integer.
|
||||
* @param source of PNG data, positioned to read the data bytes.
|
||||
* @param dataPosition offset from absolute start of bytes that data beings.
|
||||
* @param dataLength of this chunk
|
||||
* @throws IOException
|
||||
*/
|
||||
void readOtherChunk(int code, PngSource source, int dataPosition, int dataLength) throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Copy from https://github.com/aellerton/japng
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package org.jackhuang.hmcl.ui.image.apng.reader;
|
||||
|
||||
import org.jackhuang.hmcl.ui.image.apng.PngColourType;
|
||||
import org.jackhuang.hmcl.ui.image.apng.PngConstants;
|
||||
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
|
||||
|
||||
import java.io.EOFException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
/**
|
||||
* PNG reading support functions.
|
||||
*/
|
||||
public class PngReadHelper {
|
||||
|
||||
/**
|
||||
* Return true if the next 8 bytes of the InputStream match the
|
||||
* standard 8 byte PNG Signature, and false if they do not match.
|
||||
* <p>
|
||||
* If the stream ends before the signature is read an EOFExceptoin is thrown.
|
||||
* </p><p>
|
||||
* Note that no temporary buffer is allocated.
|
||||
* </p>
|
||||
*
|
||||
* @param is Stream to read the 8-byte PNG signature from
|
||||
* @throws IOException in the case of any IO exception, including EOF.
|
||||
*/
|
||||
public static boolean readSignature(InputStream is) throws IOException {
|
||||
for (int i = 0; i < PngConstants.LENGTH_SIGNATURE; i++) {
|
||||
int b = is.read();
|
||||
if (b < 0) {
|
||||
throw new EOFException();
|
||||
}
|
||||
if ((byte) b != PngConstants.BYTES_SIGNATURE[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a given InputStream using the PngReader to process all chunks until the file is
|
||||
* finished, then returning the result from the PngReader.
|
||||
*
|
||||
* @param is stream to read
|
||||
* @param reader reads and delegates processing of all chunks
|
||||
* @param <ResultT> result of the processing
|
||||
* @return result of the processing of the InputStream.
|
||||
* @throws PngException
|
||||
*/
|
||||
public static <ResultT> ResultT read(InputStream is, PngReader<ResultT> reader) throws PngException {
|
||||
try {
|
||||
if (!PngReadHelper.readSignature(is)) {
|
||||
throw new PngException(PngConstants.ERROR_NOT_PNG, "Failed to read PNG signature");
|
||||
}
|
||||
|
||||
// PngAtOnceSource source = PngAtOnceSource.from(is);//, sourceName);
|
||||
PngSource source = new PngStreamSource(is);
|
||||
boolean finished = false;
|
||||
|
||||
while (!finished) {
|
||||
int length = source.readInt();
|
||||
int code = source.readInt();
|
||||
finished = reader.readChunk(source, code, length);
|
||||
}
|
||||
|
||||
if (source.available() > 0) { // Should trailing data after IEND always be error or can configure as warning?
|
||||
throw new PngException(PngConstants.ERROR_EOF_EXPECTED, String.format("Completed IEND but %d byte(s) remain", source.available()));
|
||||
}
|
||||
|
||||
reader.finishedChunks(source);
|
||||
|
||||
return reader.getResult();
|
||||
|
||||
} catch (EOFException e) {
|
||||
throw new PngException(PngConstants.ERROR_EOF, "Unexpected EOF", e);
|
||||
} catch (IOException e) {
|
||||
throw new PngException(PngConstants.ERROR_UNKNOWN_IO_FAILURE, e.getMessage(), e);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of bytes per row is key to processing scanlines.
|
||||
* <p>
|
||||
* TODO: should this by on the header?
|
||||
*/
|
||||
public static int calculateBytesPerRow(int pixelsPerRow, byte bitDepth, PngColourType colourType, byte interlaceMethod) {
|
||||
if (interlaceMethod != 0) {
|
||||
throw new IllegalStateException("Interlaced images not yet supported");
|
||||
|
||||
} else {
|
||||
int numComponentsPerPixel = colourType.componentsPerPixel; // or "channels". e.g. "gray and alpha" means two components.
|
||||
int bitsPerComponent = bitDepth; // e.g. "4" means 4 bits for gray, 4 bits for alpha
|
||||
int bitsPerPixel = bitsPerComponent * numComponentsPerPixel; // e.g. total of 8 bits per pixel
|
||||
int bitsPerRow = bitsPerPixel * pixelsPerRow;
|
||||
|
||||
//?? use that (bitDepth+7)>>3 ... thing?
|
||||
// unsigned int bpp = (row_info->pixel_depth + 7) >> 3; // libpng
|
||||
|
||||
// If there are less than 8 bits per pixel, then ensure the last byte of the row is padded.
|
||||
int bytesPerRow = bitsPerRow / 8 + ((0 == (bitsPerRow % 8)) ? 0 : (8 - bitsPerRow % 8));
|
||||
return 1 + bytesPerRow; // need 1 byte for filter code
|
||||
}
|
||||
}
|
||||
|
||||
private PngReadHelper() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copy from https://github.com/aellerton/japng
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package org.jackhuang.hmcl.ui.image.apng.reader;
|
||||
|
||||
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* All PngReader implementations need to read a specific single chunk and to return
|
||||
* a result of some form.
|
||||
*/
|
||||
public interface PngReader<ResultT> {
|
||||
boolean readChunk(PngSource source, int code, int dataLength) throws PngException, IOException;
|
||||
|
||||
void finishedChunks(PngSource source) throws PngException, IOException;
|
||||
|
||||
ResultT getResult();
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Copy from https://github.com/aellerton/japng
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package org.jackhuang.hmcl.ui.image.apng.reader;
|
||||
|
||||
import java.io.FilterInputStream;
|
||||
import java.io.InputStream;
|
||||
|
||||
/**
|
||||
* A PngScanlineProcessor receives decompressed, de-filtered scanlines, one by one, in order
|
||||
* from top to bottom. Interlaced scanlines are not yet supported. The scanline data is not
|
||||
* parsed or expanded in any form, so if the PNG is 1-bit monochrome or 16 bit RGBA, the bytes
|
||||
* are provided as-is. The job of the processor is to read and reformat each pixel as
|
||||
* appropriate for the destination bitmap.
|
||||
* <p>
|
||||
* Note that a single instance of a PngScanlineProcessor is intended to process a single
|
||||
* bitmap and that, when the bitmap is complete, the isFinished() method will return true.
|
||||
* In the case of an animated PNG, it is expected that a new PngScanlineProcessor will be
|
||||
* created to service the new bitmap.
|
||||
* <p>
|
||||
* Note: I wonder if this would be better named PngScanlineTransformer because its primary
|
||||
* purpose is to convert pixels from raw file format to destination format.
|
||||
*/
|
||||
public interface PngScanlineProcessor {
|
||||
|
||||
/**
|
||||
* A PngScanlineProcessor is responsible for decompressing the raw (compressed) data.
|
||||
* This is important because there can be more than one IDAT and fdAT chunks for a single
|
||||
* image, and decompression must occur seamlessly across those chunks, so the decompression
|
||||
* must be stateful across multiple invocations of makeInflaterInputStream. In practice, it
|
||||
* seems to be rare that there are multiple image data chunks for a single image.
|
||||
*
|
||||
* @param inputStream stream over raw compressed PNG image data.
|
||||
* @return an InflaterInputStream that decompresses the current stream.
|
||||
*/
|
||||
FilterInputStream makeInflaterInputStream(InputStream inputStream);
|
||||
|
||||
/**
|
||||
* The processScanline method is invoked when the raw image data has been first decompressed
|
||||
* then de-filtered. The PngScanlineProcessor then must interpret each byte according to the
|
||||
* specific image format and render it to the destination as appropriate.
|
||||
*
|
||||
* @param bytes decompressed, de-filtered bytes, ready for processing.
|
||||
* @param position the position that scanline pixels begin in the array. Note that it is
|
||||
* the responsibility of the PngScanlineProcessor to know exactly how many
|
||||
* bytes are in the row. This is because a single processor might process
|
||||
* different frames in an APNG, and each frame can have a different width
|
||||
* (up to the maximum set in the PNG header).
|
||||
*/
|
||||
void processScanline(byte[] bytes, int position);
|
||||
|
||||
int getBytesPerLine();
|
||||
|
||||
void processTransparentGreyscale(byte k1, byte k0);
|
||||
|
||||
void processTransparentTruecolour(byte r1, byte r0, byte g1, byte g0, byte b1, byte b0);
|
||||
|
||||
/**
|
||||
* A PngScanlineProcessor must be able to decompress more than one consecutive IDAT or fdAT
|
||||
* chunks. This is because the PNG specification states that a valid PNG file can have more
|
||||
* than one consecutive IDAT (and by extension, fdAT) chunks and the data therein must be
|
||||
* treated as if concatenated.
|
||||
*
|
||||
* @return true when the data for the current bitmap is complete.
|
||||
*/
|
||||
boolean isFinished();
|
||||
|
||||
//InflaterInputStream connect(InputStream inputStream);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copy from https://github.com/aellerton/japng
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package org.jackhuang.hmcl.ui.image.apng.reader;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
/**
|
||||
* The API for reading any PNG source.
|
||||
*
|
||||
* @see PngAtOnceSource
|
||||
* <p>
|
||||
* WARNING: this may be removed in favour of direct use of InputStream objects.
|
||||
*/
|
||||
public interface PngSource {
|
||||
int getLength();
|
||||
|
||||
boolean supportsByteAccess();
|
||||
|
||||
byte[] getBytes() throws IOException;
|
||||
|
||||
byte readByte() throws IOException;
|
||||
|
||||
short readUnsignedShort() throws IOException;
|
||||
|
||||
int readInt() throws IOException;
|
||||
|
||||
long skip(int chunkLength) throws IOException;
|
||||
|
||||
int tell();
|
||||
|
||||
int available();
|
||||
|
||||
// String getSourceDescription();
|
||||
|
||||
// ByteArrayInputStream getBis();
|
||||
|
||||
DataInputStream getDis();
|
||||
|
||||
InputStream slice(int dataLength) throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Copy from https://github.com/aellerton/japng
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package org.jackhuang.hmcl.ui.image.apng.reader;
|
||||
|
||||
import org.jackhuang.hmcl.ui.image.apng.util.InputStreamSlice;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
/**
|
||||
* Created by aellerton on 13/06/2015.
|
||||
*/
|
||||
public class PngStreamSource implements PngSource {
|
||||
final InputStream src;
|
||||
//private final ByteArrayInputStream bis;
|
||||
private final DataInputStream dis;
|
||||
|
||||
public PngStreamSource(InputStream src) {
|
||||
this.src = src;
|
||||
//this.bis = new ByteArrayInputStream(this.bytes); // never closed because nothing to do for ByteArrayInputStream
|
||||
this.dis = new DataInputStream(this.src); // never closed because underlying stream doesn't need to be closed.
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getLength() {
|
||||
return 0; // TODO?
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsByteAccess() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] getBytes() throws IOException {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte readByte() throws IOException {
|
||||
return dis.readByte();
|
||||
}
|
||||
|
||||
@Override
|
||||
public short readUnsignedShort() throws IOException {
|
||||
return (short) dis.readUnsignedShort();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int readInt() throws IOException {
|
||||
return dis.readInt();
|
||||
}
|
||||
|
||||
@Override
|
||||
public long skip(int chunkLength) throws IOException {
|
||||
return dis.skip(chunkLength);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int tell() {
|
||||
return 0; // TODO
|
||||
}
|
||||
|
||||
@Override
|
||||
public int available() {
|
||||
try {
|
||||
return dis.available(); // TODO: adequate?
|
||||
} catch (IOException e) {
|
||||
return 0; // TODO
|
||||
}
|
||||
}
|
||||
|
||||
// @Override
|
||||
// public ByteArrayInputStream getBis() {
|
||||
// return null;
|
||||
// }
|
||||
|
||||
@Override
|
||||
public DataInputStream getDis() {
|
||||
return dis;
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream slice(int dataLength) {
|
||||
return new InputStreamSlice(src, dataLength);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Copy from https://github.com/aellerton/japng
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package org.jackhuang.hmcl.ui.image.apng.util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
/**
|
||||
* A specific-length slice of some other InputStream.
|
||||
*/
|
||||
public class InputStreamSlice extends InputStream {
|
||||
protected final InputStream src;
|
||||
protected final int length;
|
||||
protected int position = 0;
|
||||
protected boolean atEof = false;
|
||||
|
||||
public InputStreamSlice(InputStream src, int length) {
|
||||
this.src = src;
|
||||
this.length = length;
|
||||
}
|
||||
|
||||
public int tell() {
|
||||
return position;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read(byte[] b) throws IOException {
|
||||
if (atEof || position >= length) {
|
||||
atEof = true;
|
||||
return -1;
|
||||
}
|
||||
int rv = src.read(b, 0, Math.min(b.length, length - position));
|
||||
if (rv > 0) {
|
||||
position += rv;
|
||||
}
|
||||
return rv;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read(byte[] b, int off, int len) throws IOException {
|
||||
if (atEof || position >= length) {
|
||||
atEof = true;
|
||||
return -1;
|
||||
}
|
||||
int rv = src.read(b, off, Math.min(len, length - position));
|
||||
if (rv > 0) {
|
||||
position += rv;
|
||||
}
|
||||
return rv;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long skip(long n) throws IOException {
|
||||
if (atEof || position >= length) {
|
||||
atEof = true;
|
||||
return -1;
|
||||
}
|
||||
n = Math.min(available(), n); // calculate maximum skip
|
||||
long remaining = n;
|
||||
while (remaining > 0) {
|
||||
long skipped = src.skip(remaining); // attempt to skip that much
|
||||
if (skipped <= 0) {
|
||||
throw new IOException("Failed to skip a total of " + n + " bytes in stream; " + remaining + " bytes remained but " + skipped + " returned from skip.");
|
||||
}
|
||||
remaining -= skipped;
|
||||
}
|
||||
position += n; // adjust position by correct skip
|
||||
return n;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int available() throws IOException {
|
||||
if (atEof || position >= length) {
|
||||
return 0;
|
||||
}
|
||||
return length - position;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read() throws IOException {
|
||||
if (atEof || position >= length) {
|
||||
atEof = true;
|
||||
return -1;
|
||||
}
|
||||
|
||||
int rv = src.read();
|
||||
if (rv < 0) {
|
||||
atEof = true;
|
||||
} else if (rv > 0) {
|
||||
this.position += rv;
|
||||
}
|
||||
return rv;
|
||||
}
|
||||
}
|
||||
Loaded 100 of 324 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user