jafea7-ctbrec-v5.3.0-based/client/src/main/java/ctbrec/ui/tabs/ThumbCell.java

729 lines
28 KiB
Java

package ctbrec.ui.tabs;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import ctbrec.Config;
import ctbrec.GlobalThreadPool;
import ctbrec.Model;
import ctbrec.io.HttpException;
import ctbrec.recorder.Recorder;
import ctbrec.ui.AutosizeAlert;
import ctbrec.ui.Icon;
import ctbrec.ui.SiteUiFactory;
import ctbrec.ui.action.EditGroupAction;
import ctbrec.ui.action.PlayAction;
import ctbrec.ui.action.StopRecordingAction;
import ctbrec.ui.controls.Dialogs;
import ctbrec.ui.controls.RecordingIndicator;
import ctbrec.ui.controls.StreamPreview;
import javafx.animation.FadeTransition;
import javafx.animation.FillTransition;
import javafx.animation.ParallelTransition;
import javafx.animation.Transition;
import javafx.application.Platform;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.value.ObservableValue;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Cursor;
import javafx.scene.Node;
import javafx.scene.control.Alert;
import javafx.scene.control.Label;
import javafx.scene.control.Tooltip;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import javafx.scene.shape.Circle;
import javafx.scene.shape.Polygon;
import javafx.scene.shape.Rectangle;
import javafx.scene.shape.Shape;
import javafx.scene.text.Text;
import javafx.scene.text.TextAlignment;
import javafx.util.Duration;
import okhttp3.Request;
import okhttp3.Response;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.InputStream;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import static ctbrec.Model.State.OFFLINE;
import static ctbrec.Model.State.ONLINE;
import static ctbrec.io.HttpConstants.*;
import static ctbrec.ui.Icon.*;
public class ThumbCell extends StackPane {
private static final String ERROR = "Error";
private static final Logger LOG = LoggerFactory.getLogger(ThumbCell.class);
private static final Duration ANIMATION_DURATION = new Duration(250);
private static final Image imgRecordIndicator = new Image(MEDIA_RECORD_16.url());
private static final Image imgForceRecordIndicator = new Image(MEDIA_FORCE_RECORD_16.url());
private static final Image imgPauseIndicator = new Image(MEDIA_PLAYBACK_PAUSE_16.url());
private static final Image imgBookmarkIndicator = new Image(BOOKMARK_16.url());
private static final Image imgGroupIndicator = new Image(Icon.GROUP_16.url());
private ModelRecordingState modelRecordingState = ModelRecordingState.NOT;
private final Model model;
private final StreamPreview streamPreview;
private final ImageView iv;
private final Rectangle resolutionBackground;
private final Paint resolutionOnlineColor = new Color(0.22, 0.8, 0.29, 1);
private final Color resolutionOfflineColor = new Color(0.8, 0.28, 0.28, 1);
private final Rectangle nameBackground;
private final Rectangle topicBackground;
private final Rectangle selectionOverlay;
private final Text name;
private final Text topic;
private final Text resolutionTag;
private final Recorder recorder;
private final RecordingIndicator recordingIndicator;
private final Tooltip recordingIndicatorTooltip;
private StackPane previewTrigger;
private final StackPane groupIndicator;
private final Label groupIndicatorTooltipTrigger;
private int index = 0;
private static final Color colorNormal = Color.BLACK;
private static final Color colorHighlight = Color.WHITE;
private final Color colorRecording = new Color(0.8, 0.28, 0.28, .8);
private final SimpleBooleanProperty selectionProperty = new SimpleBooleanProperty(false);
private double imgAspectRatio;
private final SimpleBooleanProperty preserveAspectRatio = new SimpleBooleanProperty(true);
private final ObservableList<Node> thumbCellList;
private boolean mouseHovering = false;
private boolean recording;
static LoadingCache<Model, int[]> resolutionCache = CacheBuilder.newBuilder()
.expireAfterAccess(5, TimeUnit.MINUTES)
.maximumSize(10000)
.build(CacheLoader.from(ThumbCell::getStreamResolution));
private final ThumbOverviewTab parent;
private CompletableFuture<Boolean> startPreview;
public ThumbCell(ThumbOverviewTab parent, Model model, Recorder recorder, double aspectRatio) {
this.parent = parent;
this.thumbCellList = parent.grid.getChildren();
this.model = model;
this.recorder = recorder;
this.imgAspectRatio = aspectRatio;
recording = recorder.isTracked(model);
model.setSuspended(recorder.isSuspended(model));
model.setForcePriority(recorder.isForcePriority(model));
this.setStyle("-fx-background-color: -fx-base");
streamPreview = new StreamPreview();
streamPreview.prefWidthProperty().bind(widthProperty());
streamPreview.prefHeightProperty().bind(heightProperty());
getChildren().add(streamPreview);
iv = new ImageView();
iv.setSmooth(true);
iv.setPreserveRatio(true);
getChildren().add(iv);
topicBackground = new Rectangle();
topicBackground.setFill(Color.BLACK);
topicBackground.setOpacity(0);
StackPane.setAlignment(topicBackground, Pos.TOP_LEFT);
getChildren().add(topicBackground);
resolutionBackground = new Rectangle(34, 16);
resolutionBackground.setFill(resolutionOnlineColor);
resolutionBackground.setVisible(false);
resolutionBackground.setArcHeight(5);
resolutionBackground.setArcWidth(resolutionBackground.getArcHeight());
StackPane.setAlignment(resolutionBackground, Pos.TOP_RIGHT);
StackPane.setMargin(resolutionBackground, new Insets(2));
getChildren().add(resolutionBackground);
topic = new Text();
String txt = recording ? " " : "";
txt += model.getDescription();
topic.setText(txt);
topic.setFill(Color.WHITE);
topic.setTextAlignment(TextAlignment.LEFT);
topic.setOpacity(0);
var margin = 4;
StackPane.setMargin(topic, new Insets(margin));
StackPane.setAlignment(topic, Pos.TOP_CENTER);
getChildren().add(topic);
nameBackground = new Rectangle();
nameBackground.setFill(recording ? colorRecording : colorNormal);
nameBackground.setOpacity(.7);
StackPane.setAlignment(nameBackground, Pos.BOTTOM_CENTER);
getChildren().add(nameBackground);
name = new Text(model.getDisplayName());
name.setFill(Color.WHITE);
name.setTextAlignment(TextAlignment.CENTER);
name.getStyleClass().add("thumbcell-name");
StackPane.setAlignment(name, Pos.BOTTOM_CENTER);
getChildren().add(name);
resolutionTag = new Text();
resolutionTag.setFill(Color.WHITE);
resolutionTag.setVisible(false);
StackPane.setAlignment(resolutionTag, Pos.TOP_RIGHT);
StackPane.setMargin(resolutionTag, new Insets(2, 4, 2, 2));
getChildren().add(resolutionTag);
recordingIndicator = new RecordingIndicator(16);
recordingIndicator.setCursor(Cursor.HAND);
recordingIndicator.setOnMouseClicked(this::recordingInidicatorClicked);
recordingIndicatorTooltip = new Tooltip("Pause Recording");
Tooltip.install(recordingIndicator, recordingIndicatorTooltip);
StackPane.setMargin(recordingIndicator, new Insets(3));
StackPane.setAlignment(recordingIndicator, Pos.TOP_LEFT);
getChildren().add(recordingIndicator);
groupIndicator = new StackPane();
groupIndicator.setMaxSize(24, 24);
var groupIndicatorImg = new ImageView(imgGroupIndicator);
groupIndicatorImg.setVisible(false);
groupIndicatorImg.visibleProperty().bind(groupIndicator.visibleProperty());
groupIndicatorTooltipTrigger = new Label();
groupIndicatorTooltipTrigger.setPrefSize(16, 16);
groupIndicatorTooltipTrigger.setMinSize(16, 16);
groupIndicatorTooltipTrigger.visibleProperty().bind(groupIndicator.visibleProperty());
groupIndicatorTooltipTrigger.setCursor(Cursor.HAND);
groupIndicatorTooltipTrigger.setOnMouseClicked(e -> {
if (e.getButton() == MouseButton.PRIMARY) {
new EditGroupAction(this, recorder, model).execute();
e.consume();
}
});
var groupIndicatorBackground = new Circle(12, Color.WHITE);
groupIndicatorBackground.visibleProperty().bind(groupIndicator.visibleProperty());
groupIndicatorBackground.setOpacity(0.7);
groupIndicator.getChildren().addAll(groupIndicatorBackground, groupIndicatorImg, groupIndicatorTooltipTrigger);
StackPane.setAlignment(groupIndicator, Pos.BOTTOM_RIGHT);
getChildren().add(groupIndicator);
if (Config.getInstance().getSettings().livePreviews) {
getChildren().add(createPreviewTrigger());
}
selectionOverlay = new Rectangle();
selectionOverlay.visibleProperty().bind(selectionProperty);
selectionOverlay.widthProperty().bind(widthProperty());
selectionOverlay.heightProperty().bind(heightProperty());
StackPane.setAlignment(selectionOverlay, Pos.TOP_LEFT);
getChildren().add(selectionOverlay);
setOnMouseEntered(e -> {
mouseHovering = true;
Color normal = recording ? colorRecording : colorNormal;
new ParallelTransition(changeColor(nameBackground, normal, colorHighlight), changeColor(name, colorHighlight, normal)).playFromStart();
new ParallelTransition(changeOpacity(topicBackground, 0.7), changeOpacity(topic, 0.7)).playFromStart();
new ParallelTransition(changeOpacity(nameBackground, 1), changeOpacity(name, 1)).playFromStart();
if (Config.getInstance().getSettings().determineResolution) {
resolutionBackground.setVisible(false);
resolutionTag.setVisible(false);
}
});
setOnMouseExited(e -> {
mouseHovering = false;
Color normal = recording ? colorRecording : colorNormal;
new ParallelTransition(changeColor(nameBackground, colorHighlight, normal), changeColor(name, normal, colorHighlight)).playFromStart();
new ParallelTransition(changeOpacity(topicBackground, 0), changeOpacity(topic, 0)).playFromStart();
new ParallelTransition(changeOpacity(nameBackground, 0.7), changeOpacity(name, 0.7)).playFromStart();
if (Config.getInstance().getSettings().determineResolution && !resolutionTag.getText().isEmpty()) {
resolutionBackground.setVisible(true);
resolutionTag.setVisible(true);
}
});
setThumbWidth(Config.getInstance().getSettings().thumbWidth);
setRecording(recording);
update();
}
private void recordingInidicatorClicked(MouseEvent evt) {
switch (modelRecordingState) {
case RECORDING -> pauseResumeAction(true);
case PAUSED -> pauseResumeAction(false);
case BOOKMARKED -> forgetModel();
}
}
private void forgetModel() {
new StopRecordingAction(this, List.of(model), recorder)
.execute()
.thenAccept(r -> update());
}
private Node createPreviewTrigger() {
var s = 24;
previewTrigger = new StackPane();
previewTrigger.setStyle("-fx-background-color: white;");
previewTrigger.setOpacity(.8);
previewTrigger.setMaxSize(s, s);
var play = new Polygon(16, 8, 26, 15, 16, 22);
StackPane.setMargin(play, new Insets(0, 0, 0, 3));
play.setStyle("-fx-background-color: black;");
previewTrigger.getChildren().add(play);
var clip = new Circle(s / 2.0);
clip.setTranslateX(clip.getRadius());
clip.setTranslateY(clip.getRadius());
previewTrigger.setClip(clip);
StackPane.setAlignment(previewTrigger, Pos.BOTTOM_LEFT);
StackPane.setMargin(previewTrigger, new Insets(0, 0, 24, 4));
previewTrigger.setOnMouseEntered(evt -> startPreview());
previewTrigger.setOnMouseExited(evt -> stopPreview());
return previewTrigger;
}
private void stopPreview() {
if (startPreview != null) {
startPreview.cancel(true);
}
setPreviewVisible(previewTrigger, false);
}
private void startPreview() {
previewTrigger.setCursor(Cursor.HAND);
startPreview = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(500);
return true;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}, GlobalThreadPool.get()).whenComplete((result, exception) -> {
startPreview = null;
if (Boolean.TRUE.equals(result)) {
setPreviewVisible(previewTrigger, true);
}
});
}
private void setPreviewVisible(Node previewTrigger, boolean visible) {
parent.suspendUpdates(visible);
iv.setVisible(!visible);
topic.setVisible(!visible);
topicBackground.setVisible(!visible);
name.setVisible(!visible);
nameBackground.setVisible(!visible);
streamPreview.setVisible(visible);
if (visible) {
streamPreview.startStream(model);
} else {
streamPreview.stop();
}
recordingIndicator.setVisible(!visible);
if (!visible) {
updateRecordingIndicator();
}
previewTrigger.setCursor(visible ? Cursor.HAND : Cursor.DEFAULT);
}
public void setSelected(boolean selected) {
selectionProperty.set(selected);
selectionOverlay.setOpacity(selected ? .75 : 0);
if (selected) {
selectionOverlay.getStyleClass().add("thumbcell-selection-background");
} else {
selectionOverlay.getStyleClass().remove("thumbcell-selection-background");
}
}
public boolean isSelected() {
return selectionProperty.get();
}
public ObservableValue<Boolean> selectionProperty() {
return selectionProperty;
}
private void updateResolutionTag() {
ThumbOverviewTab.threadPool.submit(() -> {
int[] resolution;
String tagText;
Paint resolutionBackgroundColor;
try {
resolution = resolutionCache.get(model);
resolutionBackgroundColor = resolutionOnlineColor;
final int w = resolution[1];
tagText = w != Integer.MAX_VALUE ? Integer.toString(w) : "HD";
if (w == 0) {
var state = model.getOnlineState(false);
tagText = state.name();
if (model.isOnline() && state == ONLINE) {
resolutionCache.invalidate(model);
} else {
resolutionBackgroundColor = resolutionOfflineColor;
if (state == ONLINE) {
// state can't be ONLINE while the model is offline
tagText = OFFLINE.name();
}
}
} else {
var state = model.getOnlineState(true);
if (state != ONLINE) {
tagText = state.name();
resolutionBackgroundColor = resolutionOfflineColor;
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
tagText = "error";
resolutionBackgroundColor = resolutionOfflineColor;
} catch (ExecutionException | IOException e) {
tagText = "error";
resolutionBackgroundColor = resolutionOfflineColor;
}
final String text = tagText;
final Paint c = resolutionBackgroundColor;
Platform.runLater(() -> {
String oldText = resolutionTag.getText();
resolutionTag.setText(text);
if (!mouseHovering) {
resolutionTag.setVisible(true);
resolutionBackground.setVisible(true);
}
resolutionBackground.setWidth(resolutionTag.getLayoutBounds().getWidth() + 4);
resolutionBackground.setFill(c);
if (!Objects.equals(oldText, text)) {
parent.filter();
}
});
});
}
private void setImage(String url) {
if (!Objects.equals(System.getenv("CTBREC_DEV"), "1")) {
boolean updateThumbs = Config.getInstance().getSettings().updateThumbnails;
if (updateThumbs || iv.getImage() == null) {
GlobalThreadPool.submit(createThumbDownload(url));
}
}
}
private Runnable createThumbDownload(String url) {
return () -> {
Request req = new Request.Builder()
.url(url)
.header(ACCEPT, "*/*")
.header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage())
.header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent)
.header(REFERER, getModel().getSite().getBaseUrl())
.build();
try (Response resp = model.getSite().getHttpClient().execute(req)) {
if (resp.isSuccessful()) {
double width = 480;
double height = width * imgAspectRatio;
InputStream bodyStream = Objects.requireNonNull(resp.body(), "HTTP body is null").byteStream();
var img = new Image(bodyStream, width, height, preserveAspectRatio.get(), true);
if (img.progressProperty().get() == 1.0) {
Platform.runLater(() -> {
iv.setImage(img);
setThumbWidth(Config.getInstance().getSettings().thumbWidth);
});
} else {
img.progressProperty().addListener((observable, oldValue, newValue) -> {
if (newValue.doubleValue() == 1.0) {
iv.setImage(img);
setThumbWidth(Config.getInstance().getSettings().thumbWidth);
}
});
}
} else {
throw new HttpException(resp.code(), resp.message());
}
} catch (IOException e) {
LOG.warn("Error loading thumbnail: {} {}", url, e.getLocalizedMessage());
}
};
}
Image getImage() {
return iv.getImage();
}
private Transition changeColor(Shape shape, Color from, Color to) {
var transition = new FillTransition(ANIMATION_DURATION, from, to);
transition.setShape(shape);
return transition;
}
private Transition changeOpacity(Shape shape, double opacity) {
var transition = new FadeTransition(ANIMATION_DURATION, shape);
transition.setFromValue(shape.getOpacity());
transition.setToValue(opacity);
return transition;
}
void startPlayer() {
new PlayAction(this, model).execute();
}
private void setRecording(boolean recording) {
this.recording = recording;
Color c;
if (recording) {
c = mouseHovering ? colorHighlight : colorRecording;
} else {
c = mouseHovering ? colorHighlight : colorNormal;
}
nameBackground.setFill(c);
updateRecordingIndicator();
}
private void updateRecordingIndicator() {
if (recording) {
recordingIndicator.setVisible(true);
if (model.isSuspended()) {
modelRecordingState = ModelRecordingState.PAUSED;
recordingIndicator.setImage(imgPauseIndicator);
recordingIndicatorTooltip.setText("Resume Recording");
} else {
modelRecordingState = ModelRecordingState.RECORDING;
if (model.isForcePriority()) {
recordingIndicator.setImage(imgForceRecordIndicator);
} else {
recordingIndicator.setImage(imgRecordIndicator);
}
recordingIndicatorTooltip.setText("Pause Recording");
}
} else {
if (model.isMarkedForLaterRecording()) {
recordingIndicator.setVisible(true);
modelRecordingState = ModelRecordingState.BOOKMARKED;
recordingIndicator.setImage(imgBookmarkIndicator);
recordingIndicatorTooltip.setText("Forget Model");
} else {
recordingIndicator.setVisible(false);
modelRecordingState = ModelRecordingState.NOT;
recordingIndicator.setImage(null);
}
}
}
void pauseResumeAction(boolean pause) {
setCursor(Cursor.WAIT);
GlobalThreadPool.submit(() -> {
try {
if (pause) {
recorder.suspendRecording(model);
} else {
recorder.resumeRecording(model);
}
setRecording(recording);
} catch (Exception e1) {
LOG.error("Couldn't pause/resume recording", e1);
Platform.runLater(() -> {
Alert alert = new AutosizeAlert(Alert.AlertType.ERROR, getScene());
alert.setTitle(ERROR);
alert.setHeaderText("Couldn't pause/resume recording");
alert.setContentText("I/O error while pausing/resuming the recording: " + e1.getLocalizedMessage());
alert.showAndWait();
});
} finally {
Platform.runLater(() -> setCursor(Cursor.DEFAULT));
}
});
}
CompletableFuture<Boolean> follow(boolean follow) {
setCursor(Cursor.WAIT);
return CompletableFuture.supplyAsync(() -> {
try {
if (follow) {
SiteUiFactory.getUi(model.getSite()).login();
boolean followed = model.follow();
if (followed) {
return true;
} else {
Dialogs.showError(getScene(), "Couldn't follow model", "", null);
return false;
}
} else {
SiteUiFactory.getUi(model.getSite()).login();
boolean unfollowed = model.unfollow();
if (unfollowed) {
Platform.runLater(() -> thumbCellList.remove(ThumbCell.this));
return true;
} else {
Dialogs.showError(getScene(), "Couldn't unfollow model", "", null);
return false;
}
}
} catch (Exception e1) {
LOG.error("Couldn't follow/unfollow model {}", model.getName(), e1);
String msg = "I/O error while following/unfollowing model " + model.getName() + ": ";
Dialogs.showError(getScene(), "Couldn't follow/unfollow model", msg, e1);
return false;
} finally {
Platform.runLater(() -> setCursor(Cursor.DEFAULT));
}
}, GlobalThreadPool.get());
}
public Model getModel() {
return model;
}
public void setModel(Model model) {
this.model.setName(model.getName());
this.model.setDescription(model.getDescription());
this.model.setPreview(model.getPreview());
this.model.setTags(model.getTags());
this.model.setUrl(model.getUrl());
update();
}
public int getIndex() {
return index;
}
public void setIndex(int index) {
this.index = index;
}
protected void update() {
model.setSuspended(recorder.isSuspended(model));
model.setMarkedForLaterRecording(recorder.isMarkedForLaterRecording(model));
setRecording(recorder.isTracked(model));
updateRecordingIndicator();
setImage(model.getPreview());
String txt = (modelRecordingState != ModelRecordingState.NOT) ? " " : "";
txt += model.getDescription() != null ? model.getDescription() : "";
topic.setText(txt);
recorder.getModelGroup(model).ifPresentOrElse(group -> {
var tooltip = group.getName() + ": " + group.getModelUrls().size() + " models:\n";
tooltip += String.join("\n", group.getModelUrls());
groupIndicatorTooltipTrigger.setTooltip(new Tooltip(tooltip));
groupIndicator.setVisible(true);
}, () -> groupIndicator.setVisible(false));
if (Config.getInstance().getSettings().determineResolution) {
updateResolutionTag();
} else {
resolutionBackground.setVisible(false);
resolutionTag.setVisible(false);
}
requestLayout();
}
@Override
public int hashCode() {
final var prime = 31;
var result = 1;
result = prime * result + ((model == null) ? 0 : model.hashCode());
return result;
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
ThumbCell other = (ThumbCell) obj;
if (model == null) {
return other.model == null;
} else return model.equals(other.model);
}
public void setThumbWidth(int width) {
int height = (int) (width * imgAspectRatio);
setSize(width, height);
iv.prefHeight(width);
iv.prefWidth(height);
}
private void setSize(int w, int h) {
if (iv.getImage() != null) {
double aspectRatio = iv.getImage().getWidth() / iv.getImage().getHeight();
if (aspectRatio > 1) {
iv.setFitWidth(w);
} else {
iv.setFitHeight(h);
}
}
setMinSize(w, h);
setPrefSize(w, h);
nameBackground.setWidth(w);
nameBackground.setHeight(25);
topicBackground.setWidth(w);
topicBackground.setHeight(h - nameBackground.getHeight());
topic.prefHeight(getHeight() - 25);
topic.maxHeight(getHeight() - 25);
var margin = 4;
topic.maxWidth(w - margin * 2.0);
topic.setWrappingWidth(w - margin * 2.0);
streamPreview.resizeTo(w, h);
var clip = new Rectangle(w, h);
clip.setArcWidth(10);
clip.arcHeightProperty().bind(clip.arcWidthProperty());
this.setClip(clip);
}
private static int[] getStreamResolution(Model model) {
try {
return model.getStreamResolution(false);
} catch (ExecutionException e) {
LOG.trace("Error loading stream resolution for model {}: {}", model, e.getLocalizedMessage());
return new int[2];
}
}
public void releaseResources() {
iv.setImage(null);
}
public void setImageAspectRatio(double imageAspectRatio) {
this.imgAspectRatio = imageAspectRatio;
}
public BooleanProperty preserveAspectRatioProperty() {
return preserveAspectRatio;
}
private enum ModelRecordingState {
RECORDING,
PAUSED,
BOOKMARKED,
NOT
}
@Override
protected void layoutChildren() {
nameBackground.setHeight(name.getLayoutBounds().getHeight());
resolutionBackground.setHeight(resolutionTag.getLayoutBounds().getHeight());
topicBackground.setHeight(getHeight() - nameBackground.getHeight());
StackPane.setMargin(groupIndicator, new Insets(0, 3, nameBackground.getHeight() + 4, 0));
if (Config.getInstance().getSettings().livePreviews) {
StackPane.setMargin(previewTrigger, new Insets(0, 0, nameBackground.getHeight() + 4, 4));
}
super.layoutChildren();
}
}