/* * Copyright 2015-2016 Todd Kulesza <[email protected]>. * * This file is part of Archivo. * * Archivo 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. * * Archivo 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 Archivo. If not, see <http://www.gnu.org/licenses/>. */ package net.straylightlabs.archivo.view; import javafx.animation.FadeTransition; import javafx.application.Platform; import javafx.beans.binding.Bindings; import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableValue; import javafx.collections.FXCollections; import javafx.collections.ListChangeListener; import javafx.collections.ObservableList; import javafx.event.ActionEvent; import javafx.fxml.FXML; import javafx.fxml.Initializable; import javafx.scene.Cursor; import javafx.scene.control.*; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyEvent; import javafx.scene.input.MouseButton; import javafx.scene.input.MouseEvent; import javafx.scene.layout.HBox; import net.straylightlabs.archivo.Archivo; import net.straylightlabs.archivo.controller.ArchiveQueueManager; import net.straylightlabs.archivo.controller.TelemetryController; import net.straylightlabs.archivo.model.*; import net.straylightlabs.archivo.net.*; import net.straylightlabs.archivo.utilities.OSHelper; import org.controlsfx.glyphfont.FontAwesome; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.net.*; import java.time.Duration; import java.time.LocalDateTime; import java.util.*; import java.util.stream.Collectors; public class RecordingListController implements Initializable { private final ObservableList<Tivo> tivos; private final ChangeListener<? super Tivo> tivoSelectedListener; private final RecordingSelection recordingSelection; private final FadeTransition fadeTransition; private TreeItem<Recording> rootUnfiltered; private TreeItem<Recording> suggestions; private TivoSearchTask tivoSearchTask; private boolean alreadyDefaultSorted; private boolean uiDisabled; private boolean trySearchAgain; private Timer filterTimer; private final BooleanProperty tivoIsBusy; // set to true when we're communicating w/ the selected device private final Label tablePlaceholderMessage; @FXML private HBox toolbar; @FXML private ComboBox<Tivo> tivoList; @FXML private Button refreshTivoList; @FXML private TreeTableView<Recording> recordingTreeTable; @FXML private TreeTableColumn<Recording, String> showColumn; @FXML private TreeTableColumn<Recording, Duration> durationColumn; @FXML private TreeTableColumn<Recording, LocalDateTime> dateColumn; @FXML private TreeTableColumn<Recording, ArchiveStatus> statusColumn; @FXML private ProgressBar storageIndicator; @FXML private Label storageLabel; @FXML private HBox searchBar; @FXML private TextField searchField; private final Archivo mainApp; private final static Logger logger = LoggerFactory.getLogger(RecordingListController.class); /** * For the search bar. * Number of milliseconds to wait for further text input before starting to filter the recording list. */ private final static int FILTER_AFTER_MS = 400; private final static int FADE_DURATION = 500; public RecordingListController(Archivo mainApp) { recordingSelection = new RecordingSelection(); tivoIsBusy = new SimpleBooleanProperty(false); alreadyDefaultSorted = false; fadeTransition = new FadeTransition(javafx.util.Duration.millis(FADE_DURATION)); this.mainApp = mainApp; tivos = FXCollections.synchronizedObservableList(FXCollections.observableArrayList()); tablePlaceholderMessage = new Label("No recordings are available"); tivoSelectedListener = (tivoList, oldTivo, curTivo) -> { logger.info("New TiVo selected: {}", curTivo); if (curTivo != null) { mainApp.setLastDevice(curTivo); fetchRecordingsFrom(curTivo); } }; } public BooleanProperty tivoIsBusyProperty() { return tivoIsBusy; } @Override public void initialize(URL location, ResourceBundle resources) { refreshTivoList.setGraphic(mainApp.getGlyph(FontAwesome.Glyph.REFRESH)); recordingTreeTable.setShowRoot(false); recordingTreeTable.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); recordingTreeTable.setTableMenuButtonVisible(true); recordingTreeTable.setPlaceholder(tablePlaceholderMessage); recordingTreeTable.setOnSort(event -> updateGroupStatus(recordingTreeTable.getRoot(), recordingTreeTable.getRoot().getChildren()) ); recordingTreeTable.setOnMouseClicked(this::archiveOnDoubleClick); setupColumns(); setupContextMenu(); tivoList.setConverter(new Tivo.StringConverter()); tivoList.setItems(tivos); // Disable the TiVo controls when no devices are available refreshTivoList.disableProperty().bind(Bindings.or(Bindings.size(tivos).lessThan(1), tivoIsBusy)); tivoList.disableProperty().bind(Bindings.or(Bindings.size(tivos).lessThan(1), tivoIsBusy)); storageIndicator.disableProperty().bind(Bindings.or(Bindings.size(tivos).lessThan(1), tivoIsBusy)); storageLabel.disableProperty().bind(Bindings.or(Bindings.size(tivos).lessThan(1), tivoIsBusy)); recordingTreeTable.disableProperty().bind(Bindings.or(Bindings.size(tivos).lessThan(1), tivoIsBusy)); addSelectionChangedListener(recordingSelection::selectionChanged); setupStyles(); setupTransitions(); } private void setupColumns() { showColumn.setCellValueFactory(data -> data.getValue().getValue().titleProperty()); showColumn.setPrefWidth(mainApp.getUserPrefs().getTitleColumnWidth()); showColumn.widthProperty().addListener( (observable, oldValue, newValue) -> mainApp.getUserPrefs().setTitleColumnWidth(newValue.intValue()) ); durationColumn.setCellValueFactory(data -> data.getValue().getValue().durationProperty()); durationColumn.setCellFactory(col -> new DurationCellFactory()); durationColumn.setVisible(mainApp.getUserPrefs().getShowDurationColumn()); durationColumn.setPrefWidth(mainApp.getUserPrefs().getDurationColumnWidth()); durationColumn.visibleProperty().addListener( (observable, oldValue, newValue) -> mainApp.getUserPrefs().setShowDurationColumn(newValue) ); durationColumn.widthProperty().addListener( (observable, oldValue, newValue) -> mainApp.getUserPrefs().setDurationColumnWidth(newValue.intValue()) ); dateColumn.setCellValueFactory(data -> data.getValue().getValue().dateRecordedProperty()); dateColumn.setCellFactory(col -> new RecordedOnCellFactory()); dateColumn.setSortType(TreeTableColumn.SortType.DESCENDING); dateColumn.setVisible(mainApp.getUserPrefs().getShowDateColumn()); dateColumn.setPrefWidth(mainApp.getUserPrefs().getDateColumnWidth()); dateColumn.visibleProperty().addListener( (observable, oldValue, newValue) -> mainApp.getUserPrefs().setShowDateColumn(newValue) ); dateColumn.widthProperty().addListener( (observable, oldValue, newValue) -> mainApp.getUserPrefs().setDateColumnWidth(newValue.intValue()) ); statusColumn.setCellValueFactory(data -> data.getValue().getValue().statusProperty()); statusColumn.setCellFactory(col -> new StatusCellFactory(mainApp.getSymbolFont())); statusColumn.setPrefWidth(mainApp.getUserPrefs().getStatusColumnWidth()); statusColumn.widthProperty().addListener( (observable, oldValue, newValue) -> mainApp.getUserPrefs().setStatusColumnWidth(newValue.intValue()) ); } private void setupContextMenu() { final ContextMenu menu = new ContextMenu(); MenuItem archive = new MenuItem("Archive..."); archive.disableProperty().bind(recordingSelection.isArchivableProperty().not()); archive.setOnAction(event -> mainApp.getRecordingDetailsController().archive(event)); MenuItem cancel = new MenuItem("Cancel"); cancel.disableProperty().bind(recordingSelection.isCancellableProperty().not()); cancel.setOnAction(event -> mainApp.getRecordingDetailsController().cancel(event)); MenuItem play = new MenuItem("Play"); play.disableProperty().bind(recordingSelection.isPlayableProperty().not()); play.setOnAction(event -> mainApp.getRecordingDetailsController().play(event)); MenuItem openFolder = new MenuItem(String.format("Show in %s", OSHelper.getFileBrowserName())); openFolder.disableProperty().bind(recordingSelection.isPlayableProperty().not()); openFolder.setOnAction(event -> mainApp.getRecordingDetailsController().openFolder(event)); MenuItem delete = new MenuItem("Remove from TiVo..."); delete.disableProperty().bind(recordingSelection.isRemovableProperty().not()); delete.setOnAction(event -> mainApp.getRecordingDetailsController().delete(event)); menu.getItems().addAll(archive, cancel, play, openFolder, new SeparatorMenuItem(), delete); recordingTreeTable.setContextMenu(menu); } private void setupStyles() { recordingTreeTable.getStyleClass().add("recording-list"); tablePlaceholderMessage.getStyleClass().add("placeholder-message"); } private void setupTransitions() { fadeTransition.setNode(searchBar); fadeTransition.setFromValue(0); fadeTransition.setToValue(1.0); fadeTransition.setCycleCount(1); fadeTransition.setAutoReverse(false); } private void archiveOnDoubleClick(MouseEvent event) { if (event.getButton() == MouseButton.PRIMARY && event.getClickCount() > 1) { int numRecordings = recordingSelection.getRecordingsWithChildren().size(); // If the user double-clicks a group header, just expand/collapse it, don't archive everything in it if (recordingSelection.isArchivableProperty().get() && numRecordings == 1) { mainApp.archiveSelection(); } } } public Tivo getSelectedTivo() { return tivoList.getSelectionModel().getSelectedItem(); } public ObservableList<Tivo> getTivos() { return tivos; } public RecordingSelection getRecordingSelection() { return recordingSelection; } @SuppressWarnings("unused") public void fetchRecordingsFromSelectedTivo() { fetchRecordingsFrom(tivoList.getValue()); } private void fetchRecordingsFrom(Tivo tivo) { logger.debug("Fetching recordings from {}", tivo); mainApp.setStatusText("Fetching recordings..."); tivoIsBusy.setValue(true); recordingTreeTable.getSelectionModel().clearSelection(); disableUI(); MindCommandRecordingFolderItemSearch command = new MindCommandRecordingFolderItemSearch(tivo); MindTask task = new MindTask(tivo.getClient(), command); task.setOnSucceeded(event -> { logger.info("Fetching list of recordings succeeded."); fillTreeTableView(command.getSeries()); updateTivoDetails(tivo); }); task.setOnFailed(event -> { Throwable e = event.getSource().getException(); if (e instanceof MindCommandAuthException) { logger.error("Could not authenticate"); mainApp.tryNextMAK(); } else { logger.error("Error fetching recordings from {}: ", tivo.getName(), e); mainApp.showErrorMessage("Problem fetching list of recordings", String.format("Unfortunately we encountered a problem while fetching the list of available " + "recordings from %s. This usually means that either your computer or your TiVo has lost " + "its network connection.%n%nError message: %s", tivo.getName(), e.getLocalizedMessage()) ); } mainApp.clearStatusText(); tivoIsBusy.setValue(false); enableUI(); }); mainApp.getRpcExecutor().submit(task); } private void fillTreeTableView(List<Series> series) { List<TreeTableColumn<Recording, ?>> oldSortOrder = recordingTreeTable.getSortOrder().stream().collect(Collectors.toList()); rootUnfiltered = new TreeItem<>(new Recording.Builder().seriesTitle("root").build()); suggestions = new TreeItem<>(new Recording.Builder().seriesTitle("TiVo Suggestions") .isSeriesHeading(true).build()); suggestions.expandedProperty().addListener(new HeaderExpandedHandler(suggestions)); for (Series s : series) { List<Recording> recordings = s.getEpisodes(); markArchivedRecordings(recordings); markQueuedRecordings(recordings); TreeItem<Recording> item; boolean allAreSuggestions = true; if (recordings.size() > 1) { // Create a new tree node with children List<TreeItem<Recording>> childItems = new ArrayList<>(); boolean allAreCopyable = true; boolean allAreComplete = true; for (Recording recording : s.getEpisodes()) { allAreSuggestions &= recording.isSuggestion(); allAreCopyable &= !recording.isCopyProtected(); allAreComplete &= !recording.isInProgress(); recording.isChildRecording(true); childItems.add(new TreeItem<>(recording)); } RecordingState state = RecordingState.IN_PROGRESS; if (allAreComplete) { state = RecordingState.COMPLETE; } item = new TreeItem<>(new Recording.Builder().seriesTitle(s.getTitle()) .numEpisodes(s.getEpisodes().size()).recordedOn(recordings.get(0).getDateRecorded()) .isSeriesHeading(true).copyable(allAreCopyable).state(state) .image(recordings.get(0).getImageURL()).build()); item.getChildren().addAll(childItems); item.setExpanded(true); } else { // Don't create children for this node allAreSuggestions = recordings.get(0).isSuggestion(); item = new TreeItem<>(recordings.get(0)); } if (allAreSuggestions) { // If all of the recordings are TiVo Suggestions, put them under the Suggestions node suggestions.getChildren().add(item); } else { rootUnfiltered.getChildren().add(item); } } if (suggestions.getChildren().size() > 0) { rootUnfiltered.getChildren().add(suggestions); } recordingTreeTable.setRoot(rootUnfiltered); Platform.runLater(() -> { // Restore the prior sort order ObservableList<TreeTableColumn<Recording, ?>> sortOrder = recordingTreeTable.getSortOrder(); sortOrder.clear(); if (alreadyDefaultSorted) { sortOrder.addAll(oldSortOrder); } else { sortOrder.add(dateColumn); alreadyDefaultSorted = true; } recordingTreeTable.getSelectionModel().selectFirst(); recordingTreeTable.requestFocus(); }); } /** * Check each recording to see if it's in our archive history; if so, mark it as previously * archived and update its file location. */ private void markArchivedRecordings(List<Recording> recordings) { ArchiveHistory archiveHistory = mainApp.getArchiveHistory(); recordings.stream().filter(archiveHistory::contains).forEach(recording -> { ArchiveHistory.ArchiveHistoryItem historyItem = archiveHistory.get(recording); recording.setStatus(ArchiveStatus.FINISHED); recording.setDestination(historyItem.getLocation()); recording.setDateArchived(historyItem.getDateArchived()); }); } /** * Check each recording to see if it's in our queue of archive tasks; if so, replace the recording * with a reference to our previously queued task. */ private void markQueuedRecordings(List<Recording> recordings) { ArchiveQueueManager queueManager = mainApp.getArchiveQueueManager(); for (int i = 0; i < recordings.size(); i++) { Recording recording = recordings.get(i); if (!recording.isSeriesHeading() && queueManager.containsRecording(recording)) { Recording queuedRecording = queueManager.getQueuedRecording(recording); recordings.set(i, queuedRecording); } } } public void updateTivoDetails(Tivo tivo) { MindCommandBodyConfigSearch bodyConfigSearch = new MindCommandBodyConfigSearch(tivo); MindTask bodyConfigTask = new MindTask(tivo.getClient(), bodyConfigSearch); bodyConfigTask.setOnSucceeded(event -> { updateStorageControls(tivo); mainApp.clearStatusText(); tivoIsBusy.setValue(false); enableUI(); }); bodyConfigTask.setOnFailed(event -> { Throwable e = event.getSource().getException(); logger.error("Error fetching details of {}: ", tivo.getName(), e); mainApp.clearStatusText(); tivoIsBusy.setValue(false); enableUI(); }); mainApp.getRpcExecutor().submit(bodyConfigTask); } /** * Setup the "Space used" indicator to reflect the current state of @tivo. */ private void updateStorageControls(Tivo tivo) { double percent = (double) tivo.getStorageBytesUsed() / tivo.getStorageBytesTotal(); int gbUsed = (int) (tivo.getStorageBytesUsed() / (1024 * 1024)); int gbTotal = (int) (tivo.getStorageBytesTotal() / (1024 * 1024)); Tooltip storageTooltip = new Tooltip(String.format("%s is %d%% full (%,dGB of %,dGB)", tivo.getName(), (int) (percent * 100), gbUsed, gbTotal)); storageIndicator.setProgress(percent); storageIndicator.setTooltip(storageTooltip); storageLabel.setTooltip(storageTooltip); } public void startTivoSearch() { startTivoSearchWithTimeout(TivoSearchTask.SEARCH_TIMEOUT_SHORT, TivoSearchTask.TIMEOUTS_BEFORE_PROMPT); } private void startTivoSearchWithTimeout(int timeout, int retries_before_prompt) { logger.debug("startTivoSearch()"); assert (mainApp != null); if (!mainApp.getUserPrefs().getFindTivos()) { setupManualTivoConnection(); return; } recordingTreeTable.getSelectionModel().clearSelection(); removeTivoSelectedListener(); trySearchAgain = false; if (tivoSearchTask == null) { tivoSearchTask = new TivoSearchTask(tivos, mainApp.getMak(), timeout, mainApp.getUserPrefs().getNetworkInterface().getFirstAddress()); tivoSearchTask.setOnSucceeded(e -> { logger.debug("Tivo search task succeeded"); if (tivoSearchTask.searchFailed()) { logger.info("Search task failed because of a network error"); logNetworkInterfaces(); clearRecordings(); mainApp.clearStatusText(); enableUI(); mainApp.crashOccurred(); Archivo.telemetryController.sendNoTivosFoundEvent( TivoSearchTask.TIMEOUTS_BEFORE_PROMPT - retries_before_prompt, true ); trySearchAgain = mainApp.showErrorMessageWithAction("We can't seem to access your network", "Archivo encountered a problem when it tried to search for TiVos on your network.\n\n" + "This is usually caused by another program on your computer that is blocking " + "the network port Archivo needs to use.", "Try Again"); } else if (tivos.size() < 1) { if (retries_before_prompt > 0) { trySearchAgain = true; } else { logger.info("Could not find any TiVos"); logNetworkInterfaces(); clearRecordings(); mainApp.clearStatusText(); enableUI(); mainApp.crashOccurred(); Archivo.telemetryController.sendNoTivosFoundEvent( TivoSearchTask.TIMEOUTS_BEFORE_PROMPT - retries_before_prompt, false ); trySearchAgain = mainApp.showErrorMessageWithAction("We didn't find any TiVos", "Archivo couldn't find any TiVos on your network.\n\n" + "This may mean that your TiVo is too busy to respond, or that there's a problem with your network.\n\n" + "If you have a complex network configuration, you can tell Archivo which network " + "to search for TiVos on in the [Preferences dialog].", "Try Again", (event) -> mainApp.showPreferencesDialog()); } } else { Tivo lastDevice = mainApp.getLastDevice(); tivoList.getSelectionModel().clearSelection(); addTivoSelectedListener(); if (lastDevice != null && tivos.contains(lastDevice)) { logger.info("Restoring previously used tivo: {}", lastDevice); tivoList.getSelectionModel().select(lastDevice); } else { tivoList.getSelectionModel().selectFirst(); } Archivo.telemetryController.sendFoundTivosEvent( tivos.size(), TivoSearchTask.TIMEOUTS_BEFORE_PROMPT - retries_before_prompt ); } tivoSearchTask = null; if (trySearchAgain) { startTivoSearchWithTimeout(TivoSearchTask.SEARCH_TIMEOUT_LONG, retries_before_prompt - 1); } }); tivoSearchTask.setOnFailed(e -> { logger.error("Tivo search task failed: ", e.getSource().getException()); tivoSearchTask = null; }); tivoSearchTask.setOnCancelled(e -> { logger.info("Tivo search task canceled"); tivoSearchTask = null; }); } mainApp.setStatusText("Looking for TiVos..."); disableUI(); tivos.clear(); mainApp.getRpcExecutor().submit(tivoSearchTask); } private void setupManualTivoConnection() { recordingTreeTable.getSelectionModel().clearSelection(); clearRecordings(); removeTivoSelectedListener(); tivos.clear(); Tivo tivo = Tivo.fromIP(mainApp.getUserPrefs().getTivoAddress(), mainApp.getMak()); tivos.add(tivo); tivoList.getSelectionModel().clearSelection(); addTivoSelectedListener(); tivoList.getSelectionModel().selectFirst(); } private void logNetworkInterfaces() { List<String> nics = new ArrayList<>(); try { for (NetworkInterface nic : Collections.list(NetworkInterface.getNetworkInterfaces())) { if (nic.isUp()) nics.add(String.format("name='%s' isLoopback=%b isP2P=%b isVirtual=%b multicast=%b addresses=[%s]", nic.getDisplayName(), nic.isLoopback(), nic.isPointToPoint(), nic.isVirtual(), nic.supportsMulticast(), TelemetryController.getAddressesAsString(nic))); } logger.info("Localhost address: {}", InetAddress.getLocalHost()); } catch (SocketException e) { logger.error("Error fetching network interface list: ", e); } catch (UnknownHostException e) { logger.error("Error fetching localhost address: ", e); } nics.forEach(nic -> logger.debug("Found network interface: {}", nic)); } private void addTivoSelectedListener() { tivoList.getSelectionModel().selectedItemProperty().addListener(tivoSelectedListener); } private void removeTivoSelectedListener() { tivoList.getSelectionModel().selectedItemProperty().removeListener(tivoSelectedListener); } public void updateMak(String newMak) { restartTivoSearch(); tivos.forEach(tivo -> tivo.updateMak(newMak)); } private void restartTivoSearch() { if (tivoSearchTask != null) { tivoSearchTask.cancel(); } startTivoSearch(); } public void showSearchBar() { searchBar.setVisible(true); searchBar.setManaged(true); fadeTransition.playFromStart(); searchField.requestFocus(); } /** * When the text in the search field changes, check whether we should show the entire recording list or just * a subset. If a subset, start a timer so that we don't keep filtering while the user is still typing, and cancel * any existing timers. When the timer elapses, perform the filtering. */ @FXML public void searchFieldChanged(KeyEvent event) { if (event.getCode() == KeyCode.ESCAPE) { hideSearchBar(null); } else { String search = searchField.getText(); if (search.isEmpty()) { showAllRecordings(); } else { if (filterTimer != null) { filterTimer.cancel(); } filterTimer = new Timer(); filterTimer.schedule(new TimerTask() { @Override public void run() { Platform.runLater(() -> onlyShowMatchingRecordings(search)); } }, FILTER_AFTER_MS); } } } @FXML public void hideSearchBar(ActionEvent event) { searchField.clear(); showAllRecordings(); searchBar.setVisible(false); searchBar.setManaged(false); recordingTreeTable.requestFocus(); } private void onlyShowMatchingRecordings(String search) { logger.debug("Only show recordings matching '{}'", search); List<TreeItem<Recording>> selection = getSelection(); TreeItem<Recording> filteredItems = new TreeItem<>(); filteredItems.setValue(rootUnfiltered.getValue()); filterRecordings(rootUnfiltered, search, filteredItems); recordingTreeTable.setRoot(filteredItems); restoreSelection(selection); logger.debug("Completed showing recordings matching '{}'", search); } private void filterRecordings(TreeItem<Recording> node, String filter, TreeItem<Recording> filteredItems) { filter = filter.toLowerCase(); if (node.isLeaf()) { if (node.getValue().getFullTitle().toLowerCase().contains(filter)) { TreeItem<Recording> newItem = new TreeItem<>(); newItem.setValue(node.getValue()); filteredItems.getChildren().add(newItem); } } else { for (TreeItem<Recording> child : node.getChildren()) { if (child.isLeaf()) { if (child.getValue().getFullTitle().toLowerCase().contains(filter)) { TreeItem<Recording> newItem = new TreeItem<>(); newItem.setValue(child.getValue()); filteredItems.getChildren().add(newItem); } } else { TreeItem<Recording> filteredChildren = new TreeItem<>(); filteredChildren.setValue(child.getValue()); filteredChildren.setExpanded(child.isExpanded()); filterRecordings(child, filter, filteredChildren); if (filteredChildren.getChildren().size() > 0) { filteredItems.getChildren().add(filteredChildren); } } } } } private void showAllRecordings() { if (!recordingTreeTable.getRoot().equals(rootUnfiltered)) { logger.debug("showAllRecordings()"); List<TreeItem<Recording>> selection = getSelection(); recordingTreeTable.setRoot(rootUnfiltered); restoreSelection(selection); } } private void clearRecordings() { TreeItem<Recording> root = recordingTreeTable.getRoot(); if (root != null) { root.getChildren().clear(); } } private List<TreeItem<Recording>> getSelection() { return new ArrayList<>(recordingTreeTable.getSelectionModel().getSelectedItems()); } private void restoreSelection(List<TreeItem<Recording>> priorSelection) { logger.debug("Restoring selection of {} item(s)", priorSelection.size()); TreeTableView.TreeTableViewSelectionModel<Recording> selectionModel = recordingTreeTable.getSelectionModel(); selectionModel.clearSelection(); Set<Recording> selectionSet = new HashSet<>(); priorSelection.forEach(item -> selectionSet.add(item.getValue())); selectRecordings(recordingTreeTable.getRoot(), selectionSet); selectionModel.getSelectedIndices().stream().min(Integer::compare).ifPresent(i -> { logger.debug("scrolling to {}", i); recordingTreeTable.scrollTo(i); }); } private void selectRecordings(TreeItem<Recording> node, Set<Recording> recordings) { Recording recording = node.getValue(); if (recording != null && recordings.contains(recording)) { logger.debug("Selecting {}", recording.getFullTitle()); recordingTreeTable.getSelectionModel().select(node); } if (!node.isLeaf()) { node.getChildren().forEach(child -> selectRecordings(child, recordings)); } } /** * Disable the TiVo controls and the recording list */ private void disableUI() { if (!uiDisabled) { logger.debug("Disabling UI"); mainApp.getPrimaryStage().getScene().setCursor(Cursor.WAIT); uiDisabled = true; } } /** * Enable the TiVo controls and the recording list */ private void enableUI() { if (uiDisabled) { logger.debug("Enabling UI"); mainApp.getPrimaryStage().getScene().setCursor(Cursor.DEFAULT); uiDisabled = false; } } public void addSelectionChangedListener(ListChangeListener<TreeItem<Recording>> listener) { recordingTreeTable.getSelectionModel().getSelectedItems().addListener(listener); } /** * Remove @recording for the recording list's data model. */ public void removeRecording(Recording recording) { ObservableList<TreeItem<Recording>> recordingItems = recordingTreeTable.getRoot().getChildren(); int index = recordingTreeTable.getSelectionModel().getSelectedIndex(); TreeItem<Recording> treeItemAtSelection = recordingTreeTable.getSelectionModel().getModelItem(index); if (treeItemAtSelection.getValue().equals(recording)) { // Only adjust our selection when removing the recording with the lowest index recordingTreeTable.getSelectionModel().clearAndSelect(Math.max(index - 1, 0)); } removeRecordingFromList(recording, recordingItems); } /** * Recursively search our tree's data model for the recording to delete. */ private boolean removeRecordingFromList(Recording recording, ObservableList<TreeItem<Recording>> list) { Iterator<TreeItem<Recording>> i = list.iterator(); while (i.hasNext()) { TreeItem<Recording> item = i.next(); if (!item.isLeaf()) { if (removeRecordingFromList(recording, item.getChildren())) { return true; } } else { Recording other = item.getValue(); if (other.equals(recording)) { promoteSingleElementGroup(item); i.remove(); return true; } } } return false; } private void promoteSingleElementGroup(TreeItem<Recording> itemToRemove) { TreeItem<Recording> parent = itemToRemove.getParent(); int siblings = parent.getChildren().size() - 1; logger.debug("Removed a recording, leaving {} sibling(s)", siblings); if (siblings == 1) { // Find the remaining sibling TreeItem<Recording> sibling = itemToRemove.nextSibling(); if (sibling == null) { sibling = itemToRemove.previousSibling(); } // Update the title to include series and episode information sibling.getValue().isChildRecording(false); // Replace the series group with the one remaining episode int parentIndex = findItemIndex(parent); TreeItem<Recording> grandParent = parent.getParent(); grandParent.getChildren().remove(parent); grandParent.getChildren().add(parentIndex, sibling); recordingTreeTable.getSelectionModel().clearSelection(); recordingTreeTable.getSelectionModel().select(sibling); Platform.runLater(() -> { TreeItem<Recording> selectedItem = recordingTreeTable.getSelectionModel().getSelectedItem(); int selectedIndex = recordingTreeTable.getRow(selectedItem); recordingTreeTable.getFocusModel().focus(selectedIndex); }); } } private int findItemIndex(TreeItem<Recording> item) { ObservableList<TreeItem<Recording>> siblings = item.getParent().getChildren(); return siblings.indexOf(item); } private void updateGroupStatus(TreeItem<Recording> group, ObservableList<TreeItem<Recording>> list) { if (!group.isLeaf()) { Iterator<TreeItem<Recording>> i = list.iterator(); ArchiveStatus groupStatus = ArchiveStatus.EMPTY; while (i.hasNext()) { TreeItem<Recording> item = i.next(); if (!item.isLeaf()) { updateGroupStatus(item, item.getChildren()); } else { Recording recording = item.getValue(); if (groupStatus.compareTo(recording.getStatus()) > 0) { groupStatus = recording.getStatus(); } } } group.getValue().setStatus(groupStatus); } } public void expandShows() { TreeItem<Recording> selectedItem = recordingTreeTable.getSelectionModel().getSelectedItem(); expandTreeItemAndChildren(recordingTreeTable.getRoot()); Platform.runLater(() -> { int selectedIndex = recordingTreeTable.getRow(selectedItem); recordingTreeTable.scrollTo(selectedIndex); recordingTreeTable.getSelectionModel().select(selectedIndex); recordingTreeTable.getFocusModel().focus(selectedIndex); }); } public void collapseShows() { collapseTreeItemAndChildren(recordingTreeTable.getRoot()); Platform.runLater(() -> { TreeItem<Recording> selectedItem = recordingTreeTable.getSelectionModel().getSelectedItem(); int selectedIndex = recordingTreeTable.getRow(selectedItem); recordingTreeTable.scrollTo(selectedIndex); recordingTreeTable.getSelectionModel().select(selectedIndex); recordingTreeTable.getFocusModel().focus(selectedIndex); }); } private void expandTreeItemAndChildren(TreeItem<Recording> item) { item.getChildren().stream().filter(child -> !child.isLeaf()).forEach(child -> { if (child != suggestions) { child.setExpanded(true); } expandTreeItemAndChildren(child); }); } private void collapseTreeItemAndChildren(TreeItem<Recording> item) { item.getChildren().stream().filter(child -> !child.isLeaf()).forEach(child -> { if (child != suggestions) { child.setExpanded(false); } collapseTreeItemAndChildren(child); }); } /** * Ensure that when a header is expanded, it scrolls to the top of the view */ private class HeaderExpandedHandler implements ChangeListener<Boolean> { private final TreeItem<Recording> item; public HeaderExpandedHandler(TreeItem<Recording> item) { this.item = item; } @Override public void changed(ObservableValue<? extends Boolean> observable, Boolean oldValue, Boolean newValue) { recordingTreeTable.scrollTo(recordingTreeTable.getRow(item)); } } }