/** * Project-HomeFlix * * Copyright 2016-2020 * * 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, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, * MA 02110-1301, USA. * */ package org.mosad.homeflix.player; import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.List; import java.util.Timer; import java.util.TimerTask; import java.util.concurrent.TimeUnit; import org.mosad.homeflix.controller.DBController; import org.mosad.homeflix.controller.XMLController; import org.mosad.homeflix.datatypes.FilmTabelDataType; import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXDialogLayout; import com.jfoenix.controls.JFXListView; import com.jfoenix.controls.JFXPopup; import com.jfoenix.controls.JFXPopup.PopupHPosition; import com.jfoenix.controls.JFXPopup.PopupVPosition; import com.jfoenix.controls.JFXSlider; import javafx.application.Platform; import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableValue; import javafx.event.ActionEvent; import javafx.event.EventHandler; import javafx.fxml.FXML; import javafx.geometry.Insets; import javafx.scene.Cursor; import javafx.scene.control.Label; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.image.PixelBuffer; import javafx.scene.image.PixelFormat; import javafx.scene.image.WritableImage; import javafx.scene.input.MouseEvent; import javafx.scene.layout.AnchorPane; import javafx.scene.layout.HBox; import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; import javafx.scene.paint.Color; import javafx.scene.text.Text; import javafx.util.StringConverter; import uk.co.caprica.vlcj.factory.MediaPlayerFactory; import uk.co.caprica.vlcj.media.Media; import uk.co.caprica.vlcj.media.MediaEventAdapter; import uk.co.caprica.vlcj.media.MediaParsedStatus; import uk.co.caprica.vlcj.player.base.MediaPlayer; import uk.co.caprica.vlcj.player.base.TrackDescription; import uk.co.caprica.vlcj.player.embedded.EmbeddedMediaPlayer; import uk.co.caprica.vlcj.player.embedded.videosurface.CallbackVideoSurface; import uk.co.caprica.vlcj.player.embedded.videosurface.VideoSurfaceAdapters; import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormat; import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback; import uk.co.caprica.vlcj.player.embedded.videosurface.callback.RenderCallback; import uk.co.caprica.vlcj.player.embedded.videosurface.callback.format.RV32BufferFormat; /** * The PlayerController class is the main component of the HomeFlix Player. * It uses vlcj to play videos and some modified jfoenix GUI components to * create a Netflix like GUI. * * @author seil0 * * TODO this class needs heavy cleaning */ public class PlayerController { @FXML private AnchorPane panePlayer; @FXML private ImageView videoImageView; @FXML private HBox hBoxTop; @FXML private HBox controlsHBox; @FXML private VBox bottomVBox; @FXML private JFXSlider timeSlider; @FXML private JFXButton btnBack; @FXML private JFXButton btnPlay; @FXML private JFXButton btnReplay; @FXML private JFXButton btnForward; @FXML private JFXButton btnAudio; @FXML private JFXButton btnFullscreen; @FXML private JFXButton btnNextEpisode; @FXML private ImageView stopIcon; @FXML private ImageView playIcon; @FXML private ImageView fullscreenIcon; @FXML private Label lblTitle; @FXML private Label lblEndTime; private Player player; private MediaPlayerFactory mediaPlayerFactory; private EmbeddedMediaPlayer embeddedMediaPlayer; private WritableImage videoImage; private PixelBuffer videoPixelBuffer; private FilmTabelDataType media; private long startTime = 0; private long currentTime = 0; private long endTime = 0; private long skipTime = 0; private long duration = 0; private List tracks = new ArrayList<>(); private int currentTrack = 0; private int season = 0; private int episode = 0; private boolean mousePressed = false; private boolean showControls = true; private boolean autoplay; private Image playArrow = new Image("icons/baseline_play_arrow_white_48dp.png"); private Image pause = new Image("icons/baseline_pause_white_48dp.png"); private Image fullscreen = new Image("icons/baseline_fullscreen_white_48dp.png"); private Image fullscreenExit = new Image("icons/baseline_fullscreen_exit_white_48dp.png"); private JFXPopup audioPopup; // fix wrong buffer resolution private int videoWidth = 0; private int videoHeigth = 0; /** * create a new PlayerWindow object * @param player the player object (needed for closing action) * @param film the film object */ public PlayerController(Player player, String mediaURL) { this.player = player; this.media = DBController.getInstance().getStream(mediaURL); mediaPlayerFactory = new MediaPlayerFactory(); embeddedMediaPlayer = mediaPlayerFactory.mediaPlayers().newEmbeddedMediaPlayer(); embeddedMediaPlayer.videoSurface().set(new FXCallbackVideoSurface()); } public void init() { // initialize the image view videoImageView.setPreserveRatio(true); videoImageView.fitWidthProperty().bind(player.getStage().widthProperty()); videoImageView.fitHeightProperty().bind(player.getStage().heightProperty()); // set needed variables startTime = (long) DBController.getInstance().getCurrentTime(media.getStreamUrl()); autoplay = XMLController.isAutoplay(); season = !media.getSeason().isEmpty() ? Integer.parseInt(media.getSeason()) : 0; episode = !media.getEpisode().isEmpty() ? Integer.parseInt(media.getEpisode()) : 0; if (episode > 0) { // if the media is a TV show, add season + episode to the title lblTitle.setText(media.getTitle() + " S" + season + ":E" + episode); } else { lblTitle.setText(media.getTitle()); } initPlayerWindow(); initMediaPlayer(); initTimeSlider(); } /** * initialize some PlayerWindow GUI-Elements actions */ private void initPlayerWindow() { player.getScene().addEventFilter(MouseEvent.MOUSE_MOVED, new EventHandler() { // hide controls timer initialization final Timer timer = new Timer(); TimerTask controlAnimationTask = null; // task to execute save operation final long delayTime = 3000; // hide the controls after 2 seconds @Override public void handle(MouseEvent mouseEvent) { // show controls if (!showControls) { showControls = true; updateControls(); // update controls before showing them player.getScene().setCursor(Cursor.DEFAULT); hBoxTop.setVisible(true); bottomVBox.setVisible(true); showControls = true; } // hide controls if (controlAnimationTask != null) controlAnimationTask.cancel(); controlAnimationTask = new TimerTask() { @Override public void run() { // TODO a animation would be nice hBoxTop.setVisible(false); bottomVBox.setVisible(false); player.getScene().setCursor(Cursor.NONE); showControls = false; } }; timer.schedule(controlAnimationTask, delayTime); } }); if (XMLController.isFullscreen()) { player.getStage().setFullScreen(!player.getStage().isFullScreen()); fullscreenIcon.setImage(player.getStage().isFullScreen() ? fullscreenExit : fullscreen); } // fix focused button has rippler fill https://github.com/jfoenixadmin/JFoenix/issues/1051 btnBack.setStyle("-jfx-rippler-fill: black;"); } /** * initialize the embedded media player */ private void initMediaPlayer() { embeddedMediaPlayer.events().addMediaPlayerEventListener( new HFMediaPlayerEventListener() { @Override public void mediaPlayerReady(MediaPlayer mediaPlayer) { tracks = mediaPlayer.audio().trackDescriptions(); currentTrack = mediaPlayer.audio().track(); } @Override public void timeChanged(MediaPlayer mediaPlayer, long newTime) { currentTime = newTime; endTime = duration - newTime; Platform.runLater(() -> { updateControls(); }); } @Override public void error(MediaPlayer mediaPlayer) { // Auto-generated method stub } @Override public void lengthChanged(MediaPlayer mediaPlayer, long newLength) { duration = newLength; timeSlider.setMax(duration / 1000); } }); embeddedMediaPlayer.events().addMediaEventListener(new MediaEventAdapter() { @Override public void mediaParsedChanged(Media media, MediaParsedStatus newStatus) { super.mediaParsedChanged(media, newStatus); videoWidth = embeddedMediaPlayer.video().videoDimension().width; videoHeigth= embeddedMediaPlayer.video().videoDimension().height; // start the video embeddedMediaPlayer.media().play(media.newMediaRef()); embeddedMediaPlayer.controls().skipTime((long) startTime); // skip to the start time } }); } /** * initialize the time slider */ private void initTimeSlider() { // if the mouse on the timeSlider is released, skip to the new position timeSlider.setOnMouseReleased(new EventHandler() { @Override public void handle(MouseEvent event) { embeddedMediaPlayer.controls().skipTime(skipTime); // update time-stamps if video is paused if (!embeddedMediaPlayer.status().isPlaying()) { Platform.runLater(() -> { lblEndTime.setText(String.format("%d:%02d:%02d", TimeUnit.MILLISECONDS.toHours(endTime - skipTime) % 24, TimeUnit.MILLISECONDS.toMinutes(endTime - skipTime) % 60, TimeUnit.MILLISECONDS.toSeconds(endTime - skipTime) % 60)); }); } mousePressed = false; } }); timeSlider.setOnMousePressed(new EventHandler() { @Override public void handle(MouseEvent event) { mousePressed = true; } }); // on value change, get the new skip time timeSlider.valueProperty().addListener(new ChangeListener() { @Override public void changed(ObservableValue ov, Number old_val, Number new_val) { skipTime = ((new_val.longValue() * 1000) - currentTime); //System.out.println(timeSlider.getChildrenUnmodifiable()); } }); timeSlider.setOnMouseMoved(new EventHandler() { @Override public void handle(MouseEvent event) { //System.out.println("TEST"); } }); // show h:mm:ss in the animated thumb StringConverter convert = new StringConverter() { @Override public String toString(Double object) { long time = object.longValue(); return String.format("%d:%02d:%02d", TimeUnit.SECONDS.toHours(time) % 24, TimeUnit.SECONDS.toMinutes(time) % 60, TimeUnit.SECONDS.toSeconds(time) % 60); } @Override public Double fromString(String string) { return null; } }; timeSlider.setLabelFormatter(convert); // get the animated thumb as StackPane StackPane animatedThumb = (StackPane) timeSlider.lookup(".animated-thumb"); // System.out.println(animatedThumb); // System.out.println(animatedThumb.getChildren()); // modify the animated thumb Text thumbText = (Text) animatedThumb.getChildren().get(0); thumbText.setStyle("-fx-font-size: 15px; -fx-fill: white;"); // TODO add a preview to the animated thumb, if that's possible // ImageView iv = new ImageView(fullscreenExit); // animatedThumb.getChildren().add(iv); } // Start the parsing the media meta data public void start() { // parse meta data embeddedMediaPlayer.media().prepare(media.getStreamUrl()); embeddedMediaPlayer.media().parsing().parse(); } /** * Stop and release the media player. * Always call this method to stop the media player. */ public void stop() { DBController.getInstance().setCurrentTime(media.getStreamUrl(), embeddedMediaPlayer.status().time()); embeddedMediaPlayer.controls().stop(); embeddedMediaPlayer.release(); mediaPlayerFactory.release(); } /** * call this every second to update all timers */ private void updateControls() { // update control if they are visible if (showControls) { // update slider position, if the mouse does not press on the time if (!mousePressed) { timeSlider.setValue(currentTime / 1000); } // update endTime label lblEndTime.setText(String.format("%d:%02d:%02d", TimeUnit.MILLISECONDS.toHours(endTime) % 24, TimeUnit.MILLISECONDS.toMinutes(endTime) % 60, TimeUnit.MILLISECONDS.toSeconds(endTime) % 60)); } // show the next episode button 30 seconds before the end of a episode if (endTime < 31000 && episode != 0 && autoplay) { int countdown = (int) ((endTime / 1000) - 20); // a 10 seconds countdown if (!btnNextEpisode.isVisible()) { btnNextEpisode.setVisible(true); } if (endTime > 20000) { btnNextEpisode.setText(XMLController.getLocalBundle().getString("nextEpisode") + countdown + XMLController.getLocalBundle().getString("seconds")); bottomVBox.setVisible(true); } else { btnNextEpisode.setVisible(false); playNextMedia(); } } } @FXML void btnBackAction(ActionEvent event) { stop(); player.getStage().close(); } @FXML void btnPlayAction(ActionEvent event) { if (embeddedMediaPlayer.status().isPlaying()) { embeddedMediaPlayer.controls().pause(); playIcon.setImage(playArrow); } else { embeddedMediaPlayer.controls().play(); playIcon.setImage(pause); } } @FXML void btnReplayAction(ActionEvent event) { embeddedMediaPlayer.controls().skipTime(-10000); if (!embeddedMediaPlayer.status().isPlaying()) { currentTime = currentTime - 10000; endTime = endTime + 10000; Platform.runLater(() -> { updateControls(); }); } } @FXML void btnForwardAction(ActionEvent event) { embeddedMediaPlayer.controls().skipTime(10000); if (!embeddedMediaPlayer.status().isPlaying()) { currentTime = currentTime + 10000; endTime = endTime - 10000; Platform.runLater(() -> { updateControls(); }); } } @FXML void btnAudioAction(ActionEvent event) { if (audioPopup == null) { audioPopup = new JFXPopup(); JFXListView list = new JFXListView(); tracks.forEach(track -> { list.getItems().add(track.description()); }); list.getSelectionModel().select(currentTrack); list.setOnMouseClicked(ev -> { setAudioTrack(list.getSelectionModel().getSelectedIndex()); audioPopup.hide(); }); Text heading = new Text("Audio"); heading.setFill(Color.WHITE); JFXDialogLayout content = new JFXDialogLayout(); content.setPrefSize(150, 200); content.setHeading(heading); content.setBody(list); content.setPadding(new Insets(-20, -20, -20, -20)); // fix JFXDialogLayout padding content.setSpacing(-10); // fix JFXDialogLayout spacing audioPopup.setPopupContent(content); } if (!audioPopup.isShowing()) { // TODO this does not work properly audioPopup.show(btnAudio, PopupVPosition.BOTTOM, PopupHPosition.RIGHT, 0, -1 * bottomVBox.getHeight()); } else { audioPopup.hide(); } } @FXML private void btnFullscreenAction(ActionEvent event) { player.getStage().setFullScreen(!player.getStage().isFullScreen()); fullscreenIcon.setImage(player.getStage().isFullScreen() ? fullscreenExit : fullscreen); } @FXML private void btnNextEpisodeAction(ActionEvent event) { btnNextEpisode.setVisible(false); playNextMedia(); } /** * play the next media */ private void playNextMedia() { autoplay = false; DBController.getInstance().setCurrentTime(media.getStreamUrl(), 0); // reset old video start time FilmTabelDataType nextMedia = DBController.getInstance().getNextEpisode(media.getTitle(), episode, season); if (nextMedia != null) { embeddedMediaPlayer.media().play(nextMedia.getStreamUrl()); media = nextMedia; autoplay = true; } } /** * change the audio track * @param track the index of the audio track */ private void setAudioTrack(int track) { embeddedMediaPlayer.audio().setTrack(track); currentTrack = track; } public double getCurrentTime() { return currentTime; } private class FXCallbackVideoSurface extends CallbackVideoSurface { FXCallbackVideoSurface() { super(new FXBufferFormatCallback(), new FXRenderCallback(), true, VideoSurfaceAdapters.getVideoSurfaceAdapter()); } } private class FXBufferFormatCallback implements BufferFormatCallback { @Override public BufferFormat getBufferFormat(int sourceWidth, int sourceHeight) { return new RV32BufferFormat(sourceWidth, sourceHeight); } @Override public void allocatedBuffers(ByteBuffer[] buffers) { PixelFormat pixelFormat = PixelFormat.getByteBgraPreInstance(); // fixes buffer resolution, video resolution mismatch videoPixelBuffer = new PixelBuffer<>(videoWidth, videoHeigth, buffers[0], pixelFormat); //videoPixelBuffer = new PixelBuffer<>(sourceWidth, sourceHeight, buffers[0], pixelFormat); videoImage = new WritableImage(videoPixelBuffer); videoImageView.setImage(videoImage); } } private class FXRenderCallback implements RenderCallback { @Override public void display(MediaPlayer mediaPlayer, ByteBuffer[] nativeBuffers, BufferFormat bufferFormat) { Platform.runLater(() -> videoPixelBuffer.updateBuffer(pb -> null)); } } }