/*
* Autopsy Forensic Browser
*
* Copyright 2013-16 Basis Technology Corp.
* Contact: carrier <at> sleuthkit <dot> org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.sleuthkit.autopsy.imagegallery.gui.drawableviews;
import java.io.IOException;
import static java.util.Objects.nonNull;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
import java.util.logging.Level;
import javafx.application.Platform;
import javafx.beans.Observable;
import javafx.collections.ObservableList;
import javafx.concurrent.Task;
import javafx.fxml.FXML;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.control.ProgressBar;
import javafx.scene.control.ProgressIndicator;
import javafx.scene.image.Image;
import static javafx.scene.input.KeyCode.LEFT;
import static javafx.scene.input.KeyCode.RIGHT;
import javafx.scene.input.KeyEvent;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.Pane;
import javafx.scene.media.Media;
import javafx.scene.media.MediaException;
import javafx.scene.media.MediaPlayer;
import org.controlsfx.control.MaskerPane;
import org.openide.util.NbBundle;
import org.sleuthkit.autopsy.coreutils.Logger;
import org.sleuthkit.autopsy.coreutils.ThreadConfined;
import org.sleuthkit.autopsy.coreutils.ThreadConfined.ThreadType;
import org.sleuthkit.autopsy.imagegallery.FXMLConstructor;
import org.sleuthkit.autopsy.imagegallery.ImageGalleryController;
import org.sleuthkit.autopsy.imagegallery.datamodel.Category;
import org.sleuthkit.autopsy.imagegallery.datamodel.DrawableFile;
import org.sleuthkit.autopsy.imagegallery.datamodel.VideoFile;
import org.sleuthkit.autopsy.imagegallery.gui.VideoPlayer;
import static org.sleuthkit.autopsy.imagegallery.gui.drawableviews.DrawableUIBase.exec;
import static org.sleuthkit.autopsy.imagegallery.gui.drawableviews.DrawableView.CAT_BORDER_WIDTH;
/**
* Displays the files of a group one at a time. Designed to be embedded in a
* GroupPane. TODO: Extract a subclass for video files in slideshow mode-jm
*
* TODO: reduce coupling to GroupPane
*/
public class SlideShowView extends DrawableTileBase {
private static final Logger LOGGER = Logger.getLogger(SlideShowView.class.getName());
@FXML
private Button leftButton;
@FXML
private Button rightButton;
@FXML
private BorderPane footer;
@FXML
private Pane innerPane;
private volatile MediaLoadTask mediaTask;
SlideShowView(GroupPane gp, ImageGalleryController controller) {
super(gp, controller);
FXMLConstructor.construct(this, "SlideShowView.fxml"); //NON-NLS
}
@FXML
@Override
protected void initialize() {
super.initialize();
assert leftButton != null : "fx:id=\"leftButton\" was not injected: check your FXML file 'SlideShowView.fxml'.";
assert rightButton != null : "fx:id=\"rightButton\" was not injected: check your FXML file 'SlideShowView.fxml'.";
imageView.fitWidthProperty().bind(imageBorder.widthProperty().subtract(CAT_BORDER_WIDTH * 2));
imageView.fitHeightProperty().bind(heightProperty().subtract(CAT_BORDER_WIDTH * 4).subtract(footer.heightProperty()));
leftButton.setOnAction(actionEvent -> cycleSlideShowImage(-1));
rightButton.setOnAction(sctionEvent -> cycleSlideShowImage(1));
innerPane.addEventHandler(MouseEvent.MOUSE_CLICKED, clickEvent -> {
if (clickEvent.getButton() == MouseButton.PRIMARY) {
getFile().ifPresent(file -> {
final long fileID = file.getId();
getGroupPane().makeSelection(false, fileID);
if (clickEvent.getClickCount() > 1) {
getGroupPane().activateTileViewer();
}
});
clickEvent.consume();
}
});
//set up key listener equivalents of buttons
addEventFilter(KeyEvent.KEY_PRESSED, keyEvent -> {
if (keyEvent.getEventType() == KeyEvent.KEY_PRESSED) {
switch (keyEvent.getCode()) {
case LEFT:
cycleSlideShowImage(-1);
keyEvent.consume();
break;
case RIGHT:
cycleSlideShowImage(1);
keyEvent.consume();
break;
}
}
});
syncButtonVisibility();
getGroupPane().grouping().addListener(observable -> {
syncButtonVisibility();
if (getGroupPane().getGroup() != null) {
getGroupPane().getGroup().getFileIDs().addListener((Observable observable1) ->
syncButtonVisibility());
}
});
}
@ThreadConfined(type = ThreadType.ANY)
private void syncButtonVisibility() {
try {
final boolean hasMultipleFiles = getGroupPane().getGroup().getFileIDs().size() > 1;
Platform.runLater(() -> {
rightButton.setVisible(hasMultipleFiles);
leftButton.setVisible(hasMultipleFiles);
rightButton.setManaged(hasMultipleFiles);
leftButton.setManaged(hasMultipleFiles);
});
} catch (NullPointerException ex) {
// The case has likely been closed
LOGGER.log(Level.WARNING, "Error accessing groupPane"); //NON-NLS
}
}
@ThreadConfined(type = ThreadType.JFX)
public void stopVideo() {
if (imageBorder.getCenter() instanceof VideoPlayer) {
((VideoPlayer) imageBorder.getCenter()).stopVideo();
}
}
/**
* {@inheritDoc }
*/
@Override
synchronized public void setFile(final Long fileID) {
super.setFile(fileID);
getFileID().ifPresent(id -> getGroupPane().makeSelection(false, id));
getFile().ifPresent(getGroupPane()::syncCatToggle);
}
@Override
synchronized protected void disposeContent() {
stopVideo();
if (mediaTask != null) {
mediaTask.cancel(true);
}
mediaTask = null;
super.disposeContent();
}
@Override
synchronized protected void updateContent() {
disposeContent();
if (getFile().isPresent()) {
DrawableFile file = getFile().get();
if (file.isVideo()) {
doMediaLoadTask((VideoFile) file);
} else {
doReadImageTask(file);
}
}
}
synchronized private Node doMediaLoadTask(VideoFile file) {
//specially handling for videos
MediaLoadTask myTask = new MediaLoadTask(file);
mediaTask = myTask;
Node progressNode = newProgressIndicator(myTask);
Platform.runLater(() -> imageBorder.setCenter(progressNode));
//called on fx thread
myTask.setOnSucceeded(succeedded -> {
showMedia(file, myTask);
synchronized (SlideShowView.this) {
mediaTask = null;
}
});
myTask.setOnFailed(failed -> {
showErrorNode(getMediaLoadErrorLabel(myTask), file);
synchronized (SlideShowView.this) {
mediaTask = null;
}
});
myTask.setOnCancelled(cancelled -> {
disposeContent();
});
exec.execute(myTask);
return progressNode;
}
@ThreadConfined(type = ThreadConfined.ThreadType.JFX)
private void showMedia(DrawableFile file, Task<Node> mediaTask) {
//Note that all error conditions are allready logged in readImageTask.succeeded()
try {
Node mediaNode = mediaTask.get();
if (nonNull(mediaNode)) {
//we have non-null media node show it
imageBorder.setCenter(mediaNode);
} else {
showErrorNode(getMediaLoadErrorLabel(mediaTask), file);
}
} catch (InterruptedException | ExecutionException ex) {
showErrorNode(getMediaLoadErrorLabel(mediaTask), file);
}
}
private String getMediaLoadErrorLabel(Task<Node> mediaTask) {
return Bundle.DrawableUIBase_errorLabel_text() + ": " + mediaTask.getException().getLocalizedMessage();
}
/**
*
* @param file the value of file
* @param imageTask the value of imageTask
*/
@Override
Node newProgressIndicator(final Task<?> imageTask) {
MaskerPane maskerPane = new MaskerPane();
ProgressIndicator loadingProgressIndicator = new ProgressBar(-1);
maskerPane.setProgressNode(loadingProgressIndicator);
maskerPane.textProperty().bind(imageTask.messageProperty());
loadingProgressIndicator.progressProperty().bind(imageTask.progressProperty());
return maskerPane;
}
/**
* {@inheritDoc }
*/
@Override
protected String getTextForLabel() {
return getFile().map(DrawableFile::getName).orElse("") + " " + getSupplementalText();
}
/**
* cycle the image displayed in thes SlideShowview, to the next/previous one
* in the group.
*
* @param direction the direction to cycle: -1 => left / back 1 => right /
* forward
*/
@ThreadConfined(type = ThreadType.JFX)
synchronized private void cycleSlideShowImage(int direction) {
stopVideo();
final int groupSize = getGroupPane().getGroup().getFileIDs().size();
final Integer nextIndex = getFileID()
.map(fileID -> {
final int currentIndex = getGroupPane().getGroup().getFileIDs().indexOf(fileID);
return (currentIndex + direction + groupSize) % groupSize;
}).orElse(0);
setFile(getGroupPane().getGroup().getFileIDs().get(nextIndex));
}
/**
* @return supplemental text to include in the label, specifically: "image x
* of y"
*/
@NbBundle.Messages({"# {0} - file id number",
"# {1} - number of file ids",
"SlideShowView.supplementalText={0} of {1} in group"})
private String getSupplementalText() {
final ObservableList<Long> fileIds = getGroupPane().getGroup().getFileIDs();
return getFileID().map(fileID -> " ( " + Bundle.SlideShowView_supplementalText(fileIds.indexOf(fileID) + 1, fileIds.size()) + " )")
.orElse("");
}
/**
* {@inheritDoc }
*/
@Override
@ThreadConfined(type = ThreadType.ANY)
public Category updateCategory() {
Optional<DrawableFile> file = getFile();
if (file.isPresent()) {
Category updateCategory = super.updateCategory();
Platform.runLater(() -> getGroupPane().syncCatToggle(file.get()));
return updateCategory;
} else {
return Category.ZERO;
}
}
@Override
Task<Image> newReadImageTask(DrawableFile file) {
return file.getReadFullSizeImageTask();
}
@NbBundle.Messages({"# {0} - file name",
"MediaLoadTask.messageText=Reading video: {0}"})
private class MediaLoadTask extends Task<Node> {
private final VideoFile file;
MediaLoadTask(VideoFile file) {
updateMessage(Bundle.MediaLoadTask_messageText(file.getName()));
this.file = file;
}
@Override
protected Node call() throws Exception {
try {
final Media media = file.getMedia();
return new VideoPlayer(new MediaPlayer(media), file);
} catch (MediaException | IOException | OutOfMemoryError ex) {
LOGGER.log(Level.WARNING, "Failed to initialize VideoPlayer for {0} : " + ex.toString(), file.getContentPathSafe()); //NON-NLS
return doReadImageTask(file);
}
}
}
}