diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Config.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Config.java index 9d75154153..b559051b99 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Config.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Config.java @@ -448,18 +448,33 @@ public void setBackgroundPaint(Paint backgroundPaint) { @SerializedName("bgImageOpacity") private final IntegerProperty backgroundImageOpacity = new SimpleIntegerProperty(100); + @SerializedName("bgImageBlur") + private final IntegerProperty backgroundImageBlur = new SimpleIntegerProperty(0); + public IntegerProperty backgroundImageOpacityProperty() { return backgroundImageOpacity; } + public IntegerProperty backgroundImageBlurProperty() { + return backgroundImageBlur; + } + public int getBackgroundImageOpacity() { return backgroundImageOpacity.get(); } + public int getBackgroundImageBlur() { + return backgroundImageBlur.get(); + } + public void setBackgroundImageOpacity(int backgroundImageOpacity) { this.backgroundImageOpacity.set(backgroundImageOpacity); } + public void setBackgroundImageBlur(int backgroundImageBlur) { + this.backgroundImageBlur.set(backgroundImageBlur); + } + // Networks @SerializedName("autoDownloadThreads") diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/DecoratorController.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/DecoratorController.java index cdf73ed298..0540f1584c 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/DecoratorController.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/DecoratorController.java @@ -27,10 +27,8 @@ import javafx.beans.WeakInvalidationListener; import javafx.geometry.Insets; import javafx.scene.Node; -import javafx.scene.image.Image; -import javafx.scene.image.PixelReader; -import javafx.scene.image.PixelWriter; -import javafx.scene.image.WritableImage; +import javafx.scene.SnapshotParameters; +import javafx.scene.image.*; import javafx.scene.input.*; import javafx.scene.layout.*; import javafx.scene.paint.Color; @@ -83,20 +81,7 @@ public DecoratorController(Stage stage, Node mainPage) { decorator = new Decorator(stage); decorator.setOnCloseButtonAction(() -> { if (AnimationUtils.playWindowAnimation()) { - Timeline timeline = new Timeline( - new KeyFrame(Duration.millis(0), - new KeyValue(decorator.opacityProperty(), 1, Motion.EASE), - new KeyValue(decorator.scaleXProperty(), 1, Motion.EASE), - new KeyValue(decorator.scaleYProperty(), 1, Motion.EASE), - new KeyValue(decorator.scaleZProperty(), 0.3, Motion.EASE) - ), - new KeyFrame(Duration.millis(200), - new KeyValue(decorator.opacityProperty(), 0, Motion.EASE), - new KeyValue(decorator.scaleXProperty(), 0.8, Motion.EASE), - new KeyValue(decorator.scaleYProperty(), 0.8, Motion.EASE), - new KeyValue(decorator.scaleZProperty(), 0.8, Motion.EASE) - ) - ); + Timeline timeline = new Timeline(new KeyFrame(Duration.millis(0), new KeyValue(decorator.opacityProperty(), 1, Motion.EASE), new KeyValue(decorator.scaleXProperty(), 1, Motion.EASE), new KeyValue(decorator.scaleYProperty(), 1, Motion.EASE), new KeyValue(decorator.scaleZProperty(), 0.3, Motion.EASE)), new KeyFrame(Duration.millis(200), new KeyValue(decorator.opacityProperty(), 0, Motion.EASE), new KeyValue(decorator.scaleXProperty(), 0.8, Motion.EASE), new KeyValue(decorator.scaleYProperty(), 0.8, Motion.EASE), new KeyValue(decorator.scaleZProperty(), 0.8, Motion.EASE))); timeline.setOnFinished(event -> Launcher.stopApplication()); timeline.play(); } else { @@ -125,6 +110,7 @@ public DecoratorController(Stage stage, Node mainPage) { config().backgroundImageUrlProperty().addListener(weakListener); config().backgroundPaintProperty().addListener(weakListener); config().backgroundImageOpacityProperty().addListener(weakListener); + config().backgroundImageBlurProperty().addListener(weakListener); // pass key events to current dialog / current page decorator.addEventFilter(KeyEvent.ANY, e -> { @@ -196,24 +182,141 @@ public Decorator getDecorator() { @SuppressWarnings("FieldCanBeLocal") // Strong reference private final InvalidationListener changeBackgroundListener; + /** + * getRawBackgroundData + * + * @return Image (effect processing required) or Background (solid background, transparency processed) may be returned + */ + private Object getRawBackgroundData() { + EnumBackgroundImage imageType = config().getBackgroundImageType(); + if (imageType == null) { + imageType = EnumBackgroundImage.DEFAULT; + } + + Image image = null; + switch (imageType) { + case CUSTOM: + String backgroundImage = config().getBackgroundImage(); + if (backgroundImage != null) { + image = tryLoadImage(Paths.get(backgroundImage)); + } + break; + case NETWORK: + String backgroundImageUrl = config().getBackgroundImageUrl(); + if (backgroundImageUrl != null) { + try { + image = FXUtils.loadImage(backgroundImageUrl); + } catch (Exception e) { + LOG.warning("Couldn't load network background image", e); + } + } + break; + case CLASSIC: + image = newBuiltinImage("/assets/img/background-classic.jpg"); + break; + case TRANSLUCENT: + 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 color) { + Color finalColor = new Color(color.getRed(), color.getGreen(), color.getBlue(), opacity); + return new Background(new BackgroundFill(finalColor, CornerRadii.EMPTY, Insets.EMPTY)); + } else { + return new Background(new BackgroundFill(paint, CornerRadii.EMPTY, Insets.EMPTY)); + } + case DEFAULT: + default: + image = loadDefaultBackgroundImage(); + break; + } + + if (image == null) image = loadDefaultBackgroundImage(); + + return image; + } + 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); + + Task.supplyAsync(Schedulers.io(), this::getRawBackgroundData).setName("Load Background Image").whenComplete(Schedulers.javafx(), (data, loadException) -> { + if (loadException != null) { + LOG.warning("Failed to load background", loadException); + return; + } + + Task.supplyAsync(Schedulers.javafx(), () -> { + if (data instanceof Image img) { + return createBackgroundWithEffects(img); + } else if (data instanceof Background bg) { + return bg; + } + return null; + }).setName("Apply Background Effects").whenComplete(Schedulers.javafx(), (background, effectException) -> { + if (effectException == null && background != null) { + // Anti-flicker: Only apply if this is still the most recent request + if (this.changeBackgroundCount == currentCount) { + decorator.setContentBackground(background); } - }).start(); + } else if (effectException != null) { + LOG.warning("Failed to apply background effects", effectException); + } + }).start(); + }).start(); + } + + /** + * Apply both transparency and blur filters in the UI thread, and fix an issue where blurred edges are transparent + */ + private Background createBackgroundWithEffects(Image image) { + double opacity = org.jackhuang.hmcl.util.Lang.clamp(0, config().getBackgroundImageOpacity(), 100) / 100.0; + int blurRadius = config().getBackgroundImageBlur(); + + if (opacity <= 0) { + return new Background(new BackgroundFill(Color.TRANSPARENT, CornerRadii.EMPTY, Insets.EMPTY)); + } + + if (opacity >= 1.0 && blurRadius <= 0) { + return buildBackgroundImage(image); + } + + double width = image.getWidth(); + double height = image.getHeight(); + ImageView iv = new ImageView(image); + + if (opacity < 1.0) iv.setOpacity(opacity); + + if (blurRadius > 0) { + iv.setEffect(new javafx.scene.effect.GaussianBlur(blurRadius)); + + double scaleX = (width + blurRadius * 2.0) / width; + double scaleY = (height + blurRadius * 2.0) / height; + + iv.setScaleX(scaleX); + iv.setScaleY(scaleY); + } + + SnapshotParameters sp = new SnapshotParameters(); + sp.setFill(Color.TRANSPARENT); + + javafx.geometry.Rectangle2D viewport = new javafx.geometry.Rectangle2D(0, 0, width, height); + sp.setViewport(viewport); + + Image processedImage = iv.snapshot(sp, null); + + return buildBackgroundImage(processedImage); + } + + /** + * Build a unified Background object based on the image + */ + private Background buildBackgroundImage(Image image) { + return new Background(new BackgroundImage(image, BackgroundRepeat.NO_REPEAT, BackgroundRepeat.NO_REPEAT, BackgroundPosition.DEFAULT, new BackgroundSize(BackgroundSize.AUTO, BackgroundSize.AUTO, false, false, true, true))); } private Background getBackground() { EnumBackgroundImage imageType = config().getBackgroundImageType(); - if (imageType == null) - imageType = EnumBackgroundImage.DEFAULT; + if (imageType == null) imageType = EnumBackgroundImage.DEFAULT; Image image = null; switch (imageType) { @@ -246,10 +349,8 @@ private Background getBackground() { 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); + 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 @@ -259,40 +360,7 @@ private Background getBackground() { if (image == null) { image = loadDefaultBackgroundImage(); } - 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 || image.getPixelReader() == null) { - 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) - )); - } + return createBackgroundWithEffects(image); } /** @@ -300,23 +368,19 @@ private Background createBackgroundWithOpacity(Image image, int opacity) { */ private Image loadDefaultBackgroundImage() { Image image = randomImageIn(Metadata.HMCL_CURRENT_DIRECTORY.resolve("background")); - if (image != null) - return image; + if (image != null) return image; for (String extension : FXUtils.IMAGE_EXTENSIONS) { image = tryLoadImage(Metadata.HMCL_CURRENT_DIRECTORY.resolve("background." + extension)); - if (image != null) - return image; + if (image != null) return image; } image = randomImageIn(Metadata.CURRENT_DIRECTORY.resolve("bg")); - if (image != null) - return image; + if (image != null) return image; for (String extension : FXUtils.IMAGE_EXTENSIONS) { image = tryLoadImage(Metadata.CURRENT_DIRECTORY.resolve("background." + extension)); - if (image != null) - return image; + if (image != null) return image; } return newBuiltinImage("/assets/img/background.jpg"); @@ -329,10 +393,7 @@ private Image loadDefaultBackgroundImage() { List candidates; try (Stream stream = Files.list(imageDir)) { - candidates = stream - .filter(it -> FXUtils.IMAGE_EXTENSIONS.contains(getExtension(it).toLowerCase(Locale.ROOT))) - .filter(Files::isReadable) - .collect(toList()); + candidates = stream.filter(it -> FXUtils.IMAGE_EXTENSIONS.contains(getExtension(it).toLowerCase(Locale.ROOT))).filter(Files::isReadable).collect(toList()); } catch (IOException e) { LOG.warning("Failed to list files in ./bg", e); return null; @@ -342,17 +403,14 @@ private Image loadDefaultBackgroundImage() { while (!candidates.isEmpty()) { int selected = rnd.nextInt(candidates.size()); Image loaded = tryLoadImage(candidates.get(selected)); - if (loaded != null) - return loaded; - else - candidates.remove(selected); + if (loaded != null) return loaded; + else candidates.remove(selected); } return null; } private @Nullable Image tryLoadImage(Path path) { - if (!Files.isReadable(path)) - return null; + if (!Files.isReadable(path)) return null; try { return FXUtils.loadImage(path); @@ -400,8 +458,7 @@ private void refresh() { if (navigator.getCurrentPage() instanceof Refreshable) { Refreshable refreshable = (Refreshable) navigator.getCurrentPage(); - if (refreshable.refreshableProperty().get()) - refreshable.refresh(); + if (refreshable.refreshableProperty().get()) refreshable.refresh(); } } @@ -467,15 +524,13 @@ public void startWizard(WizardProvider wizardProvider) { public void startWizard(WizardProvider wizardProvider, String category) { FXUtils.checkFxUserThread(); - navigator.navigate(new DecoratorWizardDisplayer(wizardProvider, category), - ContainerAnimations.FORWARD, Motion.SHORT4, Motion.EASE); + navigator.navigate(new DecoratorWizardDisplayer(wizardProvider, category), ContainerAnimations.FORWARD, Motion.SHORT4, Motion.EASE); } // ==== Authlib Injector DnD ==== private void setupAuthlibInjectorDnD() { decorator.addEventFilter(DragEvent.DRAG_OVER, AuthlibInjectorDnD.dragOverHandler()); - decorator.addEventFilter(DragEvent.DRAG_DROPPED, AuthlibInjectorDnD.dragDroppedHandler( - url -> Controllers.dialog(new AddAuthlibInjectorServerPane(url)))); + decorator.addEventFilter(DragEvent.DRAG_DROPPED, AuthlibInjectorDnD.dragDroppedHandler(url -> Controllers.dialog(new AddAuthlibInjectorServerPane(url)))); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/PersonalizationPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/PersonalizationPage.java index bc12984fa8..575ae15021 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/PersonalizationPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/PersonalizationPage.java @@ -153,6 +153,7 @@ public PersonalizationPage() { opacityItem.setAlignment(Pos.CENTER); Label label = new Label(i18n("settings.launcher.background.settings.opacity")); + FXUtils.setLimitWidth(label, 60); JFXSlider slider = new JFXSlider(0, 100, config().getBackgroundImageType() != EnumBackgroundImage.TRANSLUCENT @@ -192,7 +193,40 @@ public void changed(ObservableValue observable, E opacityItem.getChildren().setAll(label, slider, textOpacity); } - componentList.getContent().setAll(backgroundItem, opacityItem); + HBox blurItem = new HBox(8); + { + blurItem.setAlignment(Pos.CENTER); + + Label label = new Label(i18n("settings.launcher.background.settings.blur")); + FXUtils.setLimitWidth(label, 60); + + JFXSlider blurSlider = new JFXSlider(0, 50, config().getBackgroundImageBlur()); + blurSlider.setShowTickMarks(true); + blurSlider.setMajorTickUnit(10); + blurSlider.setMinorTickCount(1); + blurSlider.setBlockIncrement(1); + blurSlider.setSnapToTicks(true); + blurSlider.setPadding(new Insets(9, 0, 0, 0)); + HBox.setHgrow(blurSlider, Priority.ALWAYS); + + if (config().getBackgroundImageType() == EnumBackgroundImage.TRANSLUCENT) { + blurSlider.setDisable(true); + } + + Label textBlur = new Label(); + FXUtils.setLimitWidth(textBlur, 50); + + StringBinding blurValueBinding = Bindings.createStringBinding(() -> ((int) blurSlider.getValue()) + "px", blurSlider.valueProperty()); + textBlur.textProperty().bind(blurValueBinding); + blurSlider.setValueFactory(s -> blurValueBinding); + + blurSlider.valueProperty().addListener((observable, oldValue, newValue) -> + config().setBackgroundImageBlur(newValue.intValue())); + + blurItem.getChildren().setAll(label, blurSlider, textBlur); + } + + componentList.getContent().setAll(backgroundItem, opacityItem, blurItem); content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("launcher.background")), componentList); } diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 735bc1546e..794fcd33f1 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1452,6 +1452,7 @@ settings.launcher.title_transparent=Transparent Titlebar settings.launcher.turn_off_animations=Disable Animation settings.launcher.version_list_source=Version List settings.launcher.background.settings.opacity=Opacity +settings.launcher.background.settings.blur=Blur settings.memory=Memory settings.memory.allocate.auto=%1$.1f GiB Minimum / %2$.1f GiB Allocated diff --git a/HMCL/src/main/resources/assets/lang/I18N_ar.properties b/HMCL/src/main/resources/assets/lang/I18N_ar.properties index 82115801fd..bd7a7ec136 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ar.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ar.properties @@ -1341,6 +1341,7 @@ settings.launcher.title_transparent=شريط العنوان الشفاف settings.launcher.turn_off_animations=تعطيل الرسوم المتحركة settings.launcher.version_list_source=قائمة الإصدارات settings.launcher.background.settings.opacity=الشفافية +settings.launcher.background.settings.blur=غامض settings.memory=الذاكرة settings.memory.allocate.auto=%1$.1f GiB الحد الأدنى / %2$.1f GiB مخصص diff --git a/HMCL/src/main/resources/assets/lang/I18N_lzh.properties b/HMCL/src/main/resources/assets/lang/I18N_lzh.properties index a2de3bb4c5..8d51842c68 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_lzh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_lzh.properties @@ -1160,6 +1160,7 @@ settings.launcher.title_transparent=通透題欄 settings.launcher.turn_off_animations=廢動效 settings.launcher.version_list_source=版列供者 settings.launcher.background.settings.opacity=陰翳 +settings.launcher.background.settings.blur=模糊 settings.memory=戲憶 settings.memory.allocate.auto=至低分之 %1$.1f GiB / 實分之 %2$.1f GiB diff --git a/HMCL/src/main/resources/assets/lang/I18N_ru.properties b/HMCL/src/main/resources/assets/lang/I18N_ru.properties index e1a9fc57be..13bc667d5d 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ru.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ru.properties @@ -1340,6 +1340,7 @@ settings.launcher.title_transparent=Прозрачная строка загол settings.launcher.turn_off_animations=Отключить анимацию settings.launcher.version_list_source=Список версий settings.launcher.background.settings.opacity=Непрозрачность +settings.launcher.background.settings.blur=Размытие settings.memory=Оперативная память settings.memory.allocate.auto=Минимум %1$.1f ГиБ / Выделено %2$.1f ГиБ diff --git a/HMCL/src/main/resources/assets/lang/I18N_uk.properties b/HMCL/src/main/resources/assets/lang/I18N_uk.properties index 06628a74d7..79c2da94a5 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_uk.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_uk.properties @@ -1287,6 +1287,7 @@ settings.launcher.title_transparent=Прозорий заголовок settings.launcher.turn_off_animations=Вимкнути анімацію settings.launcher.version_list_source=Список версій settings.launcher.background.settings.opacity=Непрозорість +settings.launcher.background.settings.blur=Размыття settings.memory=Пам'ять settings.memory.allocate.auto=%1$.1f ГіБ Мінімум / %2$.1f ГіБ Виділено diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index e5c4726298..a549941d58 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -1234,6 +1234,7 @@ settings.launcher.title_transparent=標題欄透明 settings.launcher.turn_off_animations=關閉動畫 settings.launcher.version_list_source=版本清單來源 settings.launcher.background.settings.opacity=不透明度 +settings.launcher.background.settings.blur=模糊 settings.memory=遊戲記憶體 settings.memory.allocate.auto=最低分配 %1$.1f GiB / 實際分配 %2$.1f GiB diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties index c676e1ce93..48fcb2ed12 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -1239,6 +1239,7 @@ settings.launcher.title_transparent=标题栏透明 settings.launcher.turn_off_animations=关闭动画 settings.launcher.version_list_source=版本列表源 settings.launcher.background.settings.opacity=不透明度 +settings.launcher.background.settings.blur=模糊度 settings.memory=游戏内存 settings.memory.allocate.auto=最低分配 %1$.1f GiB / 实际分配 %2$.1f GiB