Привет всем! Как я уже говорил в своем первом посте, я не программист, а скорее любитель. Пробовал писать свои поделки на разных языках, но начинал я с Java. Больше всего из семейства Java мне понравилась платформа JavaFX. Точнее сказать, связка JavaFX + FXML, где в контроллере расписываем логику, а графический интерфейс описываем в отдельном fxml-файле. Радио как раз написано с помощью этой связки.
Для воспроизведения применяется библиотека JLayer. Встроенный класс MediaPlayer почему-то отказался у меня работать. Запись и воспроизведение сделаны в отдельных потоках. Ради эксперимента пробовал запустить воспроизведение в основном потоке приложения. Получил намертво зависший интерфейс. То же самое получил и при попытке записи в основном потоке.
Полностью код приложения доступен в репозитории GitHub. Приложение было создано с помощью среды разработки NetBeans 8.2 и конструктора Scene Builder от компании Gluon. В этом посте я не ставил целью полностью рассмотреть код приложения, а лишь остановился на некоторых, самых интересных, на мой взгляд, моментах.
Внешний вид
Вот так программа выглядит:
В меню Station находятся пункты для создания, удаления и изменения станции. В меню Record можно найти пункты для начала и остановки записи, а также для изменения директории записи. В меню Reference имеется пункт для выхода из программы и пункт О Программе, показывающий некоторую информацию о приложении.
Содержимое файла разметки интерфейса. Все очень лаконично и понятно. Какие-то пояснения, я думаю, излишни.
<?xml version="1.0" encoding="UTF-8"?><?import javafx.scene.control.Button?><?import javafx.scene.control.Label?><?import javafx.scene.control.ListView?><?import javafx.scene.control.Menu?><?import javafx.scene.control.MenuBar?><?import javafx.scene.control.MenuItem?><?import javafx.scene.layout.AnchorPane?><?import javafx.scene.text.Font?><AnchorPane maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" prefHeight="300.0" prefWidth="535.0" xmlns="http://personeltest.ru/away/javafx.com/javafx/8.0.171" xmlns:fx="http://personeltest.ru/away/javafx.com/fxml/1" fx:controller="radioplayer.PlayerController"> <ListView fx:id="stationsListView" focusTraversable="false" layoutX="14.0" layoutY="36.0" prefHeight="246.0" prefWidth="200.0" AnchorPane.bottomAnchor="14.0" AnchorPane.leftAnchor="14.0" AnchorPane.topAnchor="36.0" /> <Button fx:id="playButton" focusTraversable="false" layoutX="240.0" layoutY="177.0" mnemonicParsing="false" onAction="#playAction" prefHeight="103.0" prefWidth="130.0" text="PLAY" AnchorPane.bottomAnchor="14.0" AnchorPane.rightAnchor="165.0"> <font> <Font name="System Bold" size="22.0" /> </font></Button> <Button fx:id="stopButton" focusTraversable="false" layoutX="391.0" layoutY="177.0" mnemonicParsing="false" onAction="#stopAction" prefHeight="103.0" prefWidth="130.0" text="STOP" AnchorPane.bottomAnchor="14.0" AnchorPane.rightAnchor="14.0"> <font> <Font name="System Bold" size="22.0" /> </font></Button> <Label fx:id="nameStation" layoutX="240.0" layoutY="46.0" prefHeight="113.0" prefWidth="279.0" wrapText="true" AnchorPane.bottomAnchor="141.0" AnchorPane.rightAnchor="14.0" AnchorPane.topAnchor="36.0"> <font> <Font name="System Bold Italic" size="24.0" /> </font></Label> <MenuBar prefHeight="29.0" prefWidth="535.0" AnchorPane.leftAnchor="0.0" AnchorPane.rightAnchor="0.0" AnchorPane.topAnchor="0.0"> <menus> <Menu mnemonicParsing="false" text="Station"> <items> <MenuItem mnemonicParsing="false" onAction="#addAction" text="Add" /> <MenuItem mnemonicParsing="false" onAction="#editAction" text="Edit" /> <MenuItem mnemonicParsing="false" onAction="#deleteAction" text="Delete" /> </items> </Menu> <Menu mnemonicParsing="false" text="Record"> <items> <MenuItem fx:id="recordItem" mnemonicParsing="false" onAction="#recordAction" text="To begin" /> <MenuItem fx:id="stopRecordItem" mnemonicParsing="false" onAction="#stopRecordAction" text="Stop" /> <MenuItem mnemonicParsing="false" onAction="#directoryRecordAction" text="Records Directory" /> </items> </Menu> <Menu mnemonicParsing="false" text="Reference"> <items> <MenuItem mnemonicParsing="false" onAction="#appInfoAction" text="About the program" /> <MenuItem mnemonicParsing="false" onAction="#exitAction" text="Exit" /> </items> </Menu> </menus> </MenuBar></AnchorPane>
Файл стилей (toast это всплывающие сообщения. О них позже):
.root{ -fx-background-color: grey;}.button{ -fx-background-radius: 40; -fx-border-radius: 40; -fx-text-fill: white;}.button:hover{ -fx-background-color: derive(-fx-base, 18%); -fx-border-style: solid; -fx-border-width: 1; -fx-border-color: derive(-fx-base, -15%); -fx-cursor: hand;}.button:pressed{ -fx-text-fill: black;}.list-view, .list-view .viewport, .list-view .content{ -fx-background-color: gainsboro;}.list-view:hover{ -fx-cursor: hand;}.toast{ -fx-background-radius: 30; -fx-border-radius: 30; -fx-background-color: black; -fx-padding: 20;}#nameStation{ -fx-text-fill: white;}#playButton{ -fx-background-color: blue;}#stopButton{ -fx-background-color: red;}
Воспроизведение и запись
Воспроизведение происходит с помощью этого кода:
taskPlayer = new Task() { @Override public Void call() { try { radioUrl = new URL(urlString); InputStream in = radioUrl.openStream(); InputStream is = new BufferedInputStream(in); player = new Player(is); player.play(); } catch (FileNotFoundException e) { e.getMessage(); } catch (IOException | JavaLayerException e) { e.getMessage(); } return null; } }; new Thread(taskPlayer).start();
В отличие от воспроизведения, при записи никаких сторонних библиотек не используется. Как уже говорилось, для воспроизведения применяется библиотека JLayer. Запись происходит так:
taskRecord=new Task() { @Override public Void call() throws FileNotFoundException, IOException{ output = new FileOutputStream(reader(file.getAbsolutePath())+ separator+nameStation.getText()+"-"+new Date().toString().replace(":","-")+".mp3"); InputStream in = radioUrl.openStream(); InputStream is = new BufferedInputStream(in); byte data[] = new byte[1024]; int count; while ((count = is.read(data)) != -1) { output.write(data, 0, count); } output.flush(); return null; } }; new Thread(taskRecord).start();
Станции
Станции хранятся в виде текстовых файлов, где имя файла представляет собой название станции, а содержимое это ее URL. Вот метод, который создает станции при первом запуске:
private void createDefaultStations(){ String[] stationNames = {"NonStopPlay","Classical Music","Fip Radio","Jazz Legends","Joy Radio","Live-icy","Music Radio","Radio Electron","Dubstep","Trancemission"}; String[] stationUrls = {"http://stream.nonstopplay.co.uk/nsp-128k-mp3","http://stream.srg-ssr.ch/m/rsc_de/mp3_128","http://direct.fipradio.fr/live/fip-midfi.mp3","http://jazz128legends.streamr.ru/","http://airtime.joyradio.cc:8000/airtime_192.mp3","http://live-icy.gss.dr.dk:8000/A/A05H.mp3","http://ice-the.musicradio.com/CapitalXTRANationalMP3","http://radio-electron.ru:8000/128","http://air.radiorecord.ru:8102/dub_320","http://air.radiorecord.ru:8102/tm_320"}; for(int i=0;i<10;i++){ writer(path+separator+stationNames[i], stationUrls[i]); } }
Вызов этого метода происходит из другого метода dirCreator, который создает директорию RadioStations, где хранятся файлы станций. Вот этот метод:
private void dirCreator(final String fPath) { final File file = new File(fPath); if (!file.exists()) { file.mkdir(); if(file.exists()){ alertWindow("The <RadioStations> directory has been created.\nYour radio stations will be here:\n"+fPath); createDefaultStations(); }else{ alertWindow("Error!\nThe <RadioStations> directory will not be created.\n" + "Try creating the specified directory manually in the following path:\n"+fPath+"\nThe program will be closed."); System.exit(0); } } }
Разрешения на чтение и запись
Следующие методы проверяют разрешения на чтение и запись. Если разрешение отсутствует, то пытаются установить его:
private boolean permissionRead(File file){ if(!file.canRead()){ file.setReadable(true); return !file.canRead(); } return false; } private boolean permissionWrite(File file){ if(!file.canWrite()){ file.setWritable(true); return !file.canWrite(); } return false; }
Применяются эти методы в инициализаторе для проверки разрешений для папки RadioStations:
@Override public void initialize(URL url, ResourceBundle rb) { parentPath = System.getProperty("user.home"); path=parentPath+separator+"RadioStations"; this.dirCreator(this.path); File f=new File(path); if(permissionRead(f)||permissionWrite(f)){ if(permissionRead(f)&&permissionWrite(f)){ alertWindow("Failed to get permission to read and write files to the <RadioStations> directory.\nTry to give permission manually."); }else if(permissionRead(f)){ alertWindow("Failed to get permission to read files in directory <RadioStations>.\nTry to give permission manually."); }else{ alertWindow("Failed to get permission to write files to <RadioStations> directory.\nTry to give permission manually."); } System.exit(0); } showStationsList(); stopButton.setDisable(true); recordItem.setDisable(true); stopRecordItem.setDisable(true); }
Диалоги
Для построения диалогов я не использовал визуальный конструктор, а писал все вручную. Например, вот диалог, который появляется перед записью. Программа спрашивает куда сохранять запись:
final Alert alert = new Alert(Alert.AlertType.CONFIRMATION); alert.setResizable(true); alert.getDialogPane().setPrefSize(500,200); alert.setTitle("Saving Recordings"); alert.setHeaderText(""); alert.setContentText("The default path for your recordings is:\n"+f.getAbsolutePath()+"\nChange?"); ButtonType buttonTypeEdit = new ButtonType("Edit", ButtonBar.ButtonData.OK_DONE); ButtonType buttonTypeDefault = new ButtonType("Default", ButtonBar.ButtonData.FINISH); ButtonType buttonTypeCancel = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE); alert.getButtonTypes().setAll(buttonTypeEdit, buttonTypeDefault, buttonTypeCancel); final Optional<ButtonType> resultAlert = alert.showAndWait();
Вот окно диалога:
Конечно, программа каждый раз не будет доставать пользователя такими вопросами. Перед первой записью она покажет это окно и если пользователь выберет Edit, то откроется окно выбора папки, а если выберет Default, то диалог просто закроется и запись будет вестись в папку по умолчанию. Cancel отменяет запись.
Вот еще пример диалога. Это диалог добавления станции:
Dialog dialog = new Dialog<>(); dialog.setTitle("Station Creation"); dialog.setHeaderText("Enter the name and url of the radio station"); ButtonType createButtonType = new ButtonType("Create", ButtonBar.ButtonData.OK_DONE); ButtonType cancelButtonType = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE); dialog.getDialogPane().getButtonTypes().addAll(createButtonType,cancelButtonType); GridPane grid = new GridPane(); grid.setHgap(10); grid.setVgap(10); grid.setPadding(new Insets(20, 150, 10, 10)); TextField stationName = new TextField(); TextField url = new TextField(); grid.add(new Label("Title:"), 0, 0); grid.add(stationName, 1, 0); grid.add(new Label("Url:"), 0, 1); grid.add(url, 1, 1); dialog.getDialogPane().setContent(grid); Optional<ButtonType> result = dialog.showAndWait();
Здесь все просто. Получаем окно с двумя текстовыми полями. Вот такое:
Окно диалога для изменения станций такое же, только поля заполнены данными изменяемой станции.
Заставка
Перед запуском приложения сначала появляется заставка. Для этого в проект был добавлен специальный класс. В настройках запуска проекта его нужно указать как стартовый.
package radioplayer;import javafx.application.Application;import java.awt.*;import javafx.stage.Stage;/** * * @author alex */public class Splash extends Application{ public static void main(final String[] args) { SplashScreen splash = SplashScreen.getSplashScreen(); try { Thread.sleep(3000L); } catch (InterruptedException ex) { ex.getMessage(); } if (splash != null) { splash.close(); Application.launch(RadioPlayer.class, args); } } @Override public void start(Stage primaryStage) throws Exception { throw new UnsupportedOperationException("Not supported yet."); //To change body of generated methods, choose Tools | Templates. }}
Сама заставка:
Там же в настройках нужно указать следующие параметры для виртуальной машины:
-splash:src/images/splash.png
В манифест приложения следует добавить:
SplashScreen-Image: images/splash.png
Всплывающие сообщения, как в Android
В приложении имеются всплывающие сообщения, которые выглядят как подобные сообщения в Android OS. Вот пример сообщения:
За их появления отвечает отдельный класс:
package radioplayer;import javafx.animation.KeyFrame;import javafx.animation.KeyValue;import javafx.animation.Timeline;import javafx.scene.Scene;import javafx.scene.layout.StackPane;import javafx.scene.paint.Color;import javafx.scene.text.Font;import javafx.scene.text.Text;import javafx.stage.Stage;import javafx.stage.StageStyle;import javafx.util.Duration;/** * * @author alex */public class Toast { void setMessage(final String toastMsg){ Stage toastStage=new Stage(); toastStage.setResizable(false); toastStage.initStyle(StageStyle.TRANSPARENT); Text t = new Text(toastMsg); t.setFont(Font.font("Verdana",20)); t.setFill(Color.WHITE); StackPane root = new StackPane(t); root.getStyleClass().add("toast"); root.setOpacity(0); Scene scene = new Scene(root); scene.getStylesheets().add((getClass().getResource("style.css")).toExternalForm()); scene.setFill(null); toastStage.setScene(scene); toastStage.show(); Timeline tl1 = new Timeline(); KeyFrame fadeInKey1 = new KeyFrame(Duration.millis(500),new KeyValue (toastStage.getScene().getRoot().opacityProperty(), 1)); tl1.getKeyFrames().add(fadeInKey1); tl1.setOnFinished((ae) -> new Thread(() -> { try { Thread.sleep(3000); } catch (InterruptedException e) { e.getMessage(); } Timeline tl2 = new Timeline(); KeyFrame fadeOutKey1 = new KeyFrame(Duration.millis(500), new KeyValue(toastStage.getScene().getRoot().opacityProperty(), 0)); tl2.getKeyFrames().add(fadeOutKey1); tl2.setOnFinished((aeb) -> toastStage.close()); tl2.play(); }).start()); tl1.play(); }}
Сборка
Если создавать исполняемый архив, просто нажав в NetBeans кнопку очистки и сборки проекта, мы получим архив, который не будет содержать в себе классы библиотеки JLayer. В манифесте этого архива будет прописан путь до библиотеки. Программа будет работать, только если библиотека будет расположена по этому пути.
Чтобы классы библиотеки JLayer запаковать в исполняемый архив, нужно в файле build.xml дописать следующее:
<target name="package-for-store" depends="jar"> <property name="store.jar.name" value="Radio"/> <property name="store.dir" value="store"/> <property name="store.jar" value="${store.dir}/${store.jar.name}.jar"/> <echo message="Packaging ${application.title} into a single JAR at ${store.jar}"/> <delete dir="${store.dir}"/> <mkdir dir="${store.dir}"/> <jar destfile="${store.dir}/temp_final.jar" filesetmanifest="skip"> <zipgroupfileset dir="dist" includes="*.jar"/> <zipgroupfileset dir="dist/lib" includes="*.jar"/> <manifest> <attribute name="Main-Class" value="radioplayer.Splash"/> <attribute name="SplashScreen-Image" value="images/splash.png"/> </manifest> </jar> <zip destfile="${store.jar}"> <zipfileset src="${store.dir}/temp_final.jar" excludes="META-INF/*.SF, META-INF/*.DSA, META-INF/*.RSA"/> </zip> <delete file="${store.dir}/temp_final.jar"/></target>
Для сборки в меню нужно выбрать выполнить цель, а в подменю найти package-for-store. В папке store появится готовый архив.
Дополнительная ссылка на SourceForge. До встречи в следующих постах!