company_banner

Радио с записью станций на языке Java

    Привет всем! Как я уже говорил в своем первом посте, я не программист, а скорее любитель. Пробовал писать свои поделки на разных языках, но начинал я с 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://javafx.com/javafx/8.0.171" xmlns:fx="http://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. До встречи в следующих постах!

    ITSOFT
    Дата-центры: размещение и аренда серверов и стоек.

    Комментарии 3

      +1
      Запускать потоки вручную — плохо. Для этого лучше использовать Executor. В данном случае, думаю, будет достаточно пула из двух потоков.

      InputStream-ы после воспроизведения не закрываются, что так же не есть хорошо.
        +1
        И ещё, по-моему, не очень хорошая идея перепаковывать LGPL-зависимость в свой fat-jar, потому как одним из условий лицензии является возможность обновления этой библиотеки пользователем, а сделать это без пересборки fat-jar-а вряд ли получится без костылей.
        Потому советую подумать над дистрибуцией приложения, раз уж предполагается его распространение через sourceforge.
          0
          Спасибо большое за комментарии! Постараюсь применить ваши советы в обновлениях и новых разработках.

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое