Search code examples
javaswingjavasoundclipjslider

JSlider issue audio player


I am trying to build an audio player with an integrated JSlider, which updates the interface every microsecond.

In order to do so I am using the following:

sliderTime.setMinimum(0);
sliderTime.setMaximum((int) audioClip.getMicrosecondPosition(););

I have the feeling that this is not the best implementation out there (any suggestions to improve it is highly appreciated)

By the way, the issue I am facing is that for the first second the JSlider does not update.

Please find MCVE below: It plays only wav uncompressed files

Main

public class Main
{
    public static void main(final String[] args) 
    {
        SwingUtilities.invokeLater(new Runnable() 
        {
             @Override
             public void run() 
             {          
                 JFrame f = new JFrame();
                 PlayerView pw = new PlayerView();
                 Border border = new EmptyBorder(15,15,15,15);
                 pw.setBorder(border);
                 f.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
                 f.getContentPane().setLayout(new BorderLayout());
                 f.getContentPane().add(pw, BorderLayout.CENTER);
                 f.pack();
                 f.setLocationRelativeTo(null);
                 f.setVisible(true);
             }
        });
    }
}

AudioPlayer

public class AudioPlayer implements LineListener 
{
    private SimpleDateFormat dateFormater = new SimpleDateFormat("HH:mm:ss.SSS");   
    private TimeZone timeZone = Calendar.getInstance().getTimeZone();  

    public static final int REWIND_IN_MICROSECONDS = 3000000;
    public static final int FORWARD_IN_MICROSECONDS = 3000000;

    private boolean playCompleted;
    private boolean isStopped;
    private boolean isPaused;
    private boolean isRewinded;
    private boolean isForwarded;

    private Clip audioClip;

    public Clip getAudioClip() 
    {
        return audioClip;
    }   

    public void load(String audioFilePath) throws UnsupportedAudioFileException, IOException, LineUnavailableException 
    {
        File encodedFile = new File(audioFilePath);
        AudioInputStream pcmStream = AudioSystem.getAudioInputStream(encodedFile);
        AudioFormat format =pcmStream.getFormat();
        DataLine.Info info = new DataLine.Info(Clip.class, format);
        audioClip = (Clip) AudioSystem.getLine(info);
        audioClip.addLineListener(this);
        audioClip.open(pcmStream);
    }

    public long getClipMicroSecondLength() 
    {
        return audioClip.getMicrosecondLength();
    }

    public long getClipMicroSecondPosition() 
    {
        return audioClip.getMicrosecondPosition();
    }

    public String getClipLengthString() 
    {   
        long yourmilliseconds = audioClip.getMicrosecondLength() / 1_000;
        Date resultdate = new Date(yourmilliseconds);
        dateFormater.setTimeZone(TimeZone.getTimeZone(timeZone.getDisplayName(false, TimeZone.SHORT))); 
        return dateFormater.format(resultdate);
    }

    public void play() throws IOException 
    {
        audioClip.start();

        playCompleted = false;
        isStopped = false;

        while (!playCompleted) 
        {
            try 
            {
                Thread.sleep(30);
            } 
            catch (InterruptedException ex) 
            {
                if (isStopped)
                {
                    audioClip.stop();
                    break;
                }
                else if (isPaused) 
                {
                    audioClip.stop();
                } 
                else if (isRewinded) 
                {
                    if( audioClip.getMicrosecondPosition() <= REWIND_IN_MICROSECONDS)
                    {
                        audioClip.setMicrosecondPosition(0);
                        isRewinded =false;
                    }
                    else
                    {
                        audioClip.setMicrosecondPosition(audioClip.getMicrosecondPosition() - REWIND_IN_MICROSECONDS);
                        isRewinded =false;
                    }
                }
                else if (isForwarded) 
                {
                    if((audioClip.getMicrosecondLength() - audioClip.getMicrosecondPosition()) >= FORWARD_IN_MICROSECONDS)
                    {
                        audioClip.setMicrosecondPosition(audioClip.getMicrosecondPosition() + FORWARD_IN_MICROSECONDS);
                        isForwarded =false;
                    }
                    else
                    {
                        audioClip.stop();
                        isForwarded =false;
                    }
                } 
                else 
                {
                    audioClip.start();
                }
            }
        }
        audioClip.close();
    }

    public void stop() 
    {
        isStopped = true;
    }

    public void pause() 
    {
        isPaused = true;
    }

    public void resume() 
    {
        isPaused = false;
    }

    public void rewind() 
    {
        isRewinded = true;
    }

    public void forward() 
    {
        isForwarded = true;
    }

    @Override
    public void update(LineEvent event) 
    {
        Type type = event.getType();
        if (type == Type.STOP) 
        {
            if (isStopped || !isPaused) 
            {
                playCompleted = true;
            }
        }
    }
}

PlayingTimer

public class PlayingTimer extends Thread 
{
    private SimpleDateFormat dateFormater = new SimpleDateFormat("HH:mm:ss.SSS");   
    private TimeZone timeZone = Calendar.getInstance().getTimeZone();  

    private boolean isRunning = false;
    private boolean isPause = false;
    private boolean isReset = false;
    private boolean isRewinded = false;
    private boolean isForwarded = false;

    private long startTime;
    private long pauseTime;
    private long rewindTime;
    private long forwardTime;

    private JLabel labelRecordTime;
    private JSlider slider;
    private Clip audioClip;

    public void setAudioClip(Clip audioClip) 
    {
        this.audioClip = audioClip;
    }

    public PlayingTimer(JLabel labelRecordTime, JSlider slider) 
    {
        this.labelRecordTime = labelRecordTime;
        this.slider = slider;
        dateFormater.setTimeZone(TimeZone.getTimeZone(timeZone.getDisplayName(false, TimeZone.SHORT))); 
    }

    public void run() 
    {
        isRunning = true;
        startTime = System.currentTimeMillis();

        while (isRunning) 
        {
            try 
            {
                Thread.sleep(30);
                if (!isPause) 
                {
                    if (audioClip != null && audioClip.isRunning()) 
                    {
                         long currentMicros = audioClip.getMicrosecondPosition();

                            // Compute the progress as a value between 0.0 and 1.0
                            double progress = 
                                (double)currentMicros / audioClip.getMicrosecondLength();

                            // Compute the slider value to indicate the progress
                            final int sliderValue = (int)(progress * slider.getMaximum());


                            // Update the slider with the new value, on the Event Dispatch Thread
                            SwingUtilities.invokeLater(new Runnable()
                            {
                                @Override
                                public void run()
                                {
                                    labelRecordTime.setText(toTimeString());
                                    slider.setValue(sliderValue);
                                }
                            });
                    }
                }
                else 
                {
                    pauseTime += 30;
                }
            }
            catch (InterruptedException ex) 
            {
                if (isReset) 
                {
                    slider.setValue(0);
                    labelRecordTime.setText("00:00:00.000");
                    isRunning = false;      
                    break;
                }
                if (isRewinded) 
                {
                    if( audioClip.getMicrosecondPosition() <= AudioPlayer.REWIND_IN_MICROSECONDS)
                    {
                        //go back to start
                        rewindTime += audioClip.getMicrosecondPosition() / 1_000;
                    }
                    else
                    {
                        rewindTime += 3000;
                    }
                    isRewinded =false;
                }
                if (isForwarded) 
                {
                    if((audioClip.getMicrosecondLength()- audioClip.getMicrosecondPosition()) <= AudioPlayer.FORWARD_IN_MICROSECONDS)
                    {
                        forwardTime -= (audioClip.getMicrosecondLength()- audioClip.getMicrosecondPosition())/1_000; 
                    }
                    else
                    {                   
                        forwardTime -= 3000;
                    }
                    isForwarded=false;
                }
            }
        } 
    }

    public void reset() 
    {
        isReset = true;
        isRunning = false;
    }

    public void rewind() 
    {
        isRewinded = true;
    }

    public void forward() 
    {
        isForwarded = true;
    }

    public void pauseTimer() 
    {
        isPause = true;
    }

    public void resumeTimer() 
    {
        isPause = false;
    }

    private String toTimeString() 
    {
        long now = System.currentTimeMillis();
        Date resultdate = new Date(now - startTime - pauseTime - rewindTime - forwardTime);
        return dateFormater.format(resultdate);
    }

}

PlayerView

 public class PlayerView extends JPanel implements ActionListener 
   {

        private static final int BUTTON_HEIGTH =60; 
        private static final int BUTTON_WIDTH =120; 

        private AudioPlayer player = new AudioPlayer();
        private Thread playbackThread;
        private PlayingTimer timer;

        private boolean isPlaying = false;
        private boolean isPause = false;

        private String audioFilePath;
        private String lastOpenPath;

        private JLabel labelFileName;
        private JLabel labelTimeCounter;
        private JLabel labelDuration;

        private JButton buttonOpen;
        private JButton buttonPlay;
        private JButton buttonPause;
        private JButton buttonRewind;
        private JButton buttonForward;

        private JSlider sliderTime;

        private Dimension buttonDimension = new Dimension(BUTTON_WIDTH,BUTTON_HEIGTH);

        public PlayerView() 
        {
            setLayout(new BorderLayout());
            labelFileName = new JLabel("File Loaded:");

            labelTimeCounter = new JLabel("00:00:00.000");
            labelDuration = new JLabel("00:00:00.000");

            sliderTime = new JSlider(0, 1000, 0);;
            sliderTime.setValue(0);
            sliderTime.setEnabled(false);

            buttonOpen   = new JButton("Open");
            buttonOpen.setPreferredSize(buttonDimension);
            buttonOpen.addActionListener(this);

            buttonPlay   = new JButton("Play");

            buttonPlay.setEnabled(false);
            buttonPlay.setPreferredSize(buttonDimension);
            buttonPlay.addActionListener(this);

            buttonPause  = new JButton("Pause");
            buttonPause.setEnabled(false);
            buttonPause.setPreferredSize(buttonDimension);
            buttonPause.addActionListener(this);

            buttonRewind = new JButton("Rewind");
            buttonRewind.setEnabled(false);
            buttonRewind.setPreferredSize(buttonDimension);
            buttonRewind.addActionListener(this);

            buttonForward= new JButton("Forward");
            buttonForward.setEnabled(false);
            buttonForward.setPreferredSize(buttonDimension);
            buttonForward.addActionListener(this);

            init();

        }

        public void enableButtonPlay()
        {
            buttonPlay.setEnabled(true);
        }

        @Override
        public void actionPerformed(ActionEvent event) 
        {
            Object source = event.getSource();
            if (source instanceof JButton) 
            {
                JButton button = (JButton) source;
                if (button == buttonOpen) 
                {
                    openFile();
                } 
                else if (button == buttonPlay) 
                {
                    if (!isPlaying) 
                    {
                        playBack();
                    } 
                    else 
                    {
                        stopPlaying();
                    }
                } 
                else if (button == buttonPause) 
                {
                    if (!isPause) 
                    {
                        pausePlaying();
                    } 
                    else 
                    {
                        resumePlaying();
                    }
                }
                else if (button == buttonRewind) 
                {
                    if (!isPause) 
                    {
                        rewind();                   
                    } 
                }
                else if (button == buttonForward) 
                {
                    if (!isPause) 
                    {
                        forward();
                    } 
                }
            }
        }

        public void openFile(String path) 
        {
            audioFilePath = path ;

            if (isPlaying || isPause) 
            {
                stopPlaying();
                while (player.getAudioClip().isRunning()) 
                {
                    try 
                    {
                        Thread.sleep(100);
                    } 
                    catch (InterruptedException ex) 
                    {
                        ex.printStackTrace();
                    }
                }
            }
            playBack();
        }

        private void openFile() 
        {
            JFileChooser fileChooser = null;

            if (lastOpenPath != null && !lastOpenPath.equals("")) 
            {
                fileChooser = new JFileChooser(lastOpenPath);
            } 
            else 
            {
                fileChooser = new JFileChooser();
            }

            FileFilter wavFilter = new FileFilter() 
            {
                @Override
                public String getDescription() 
                {
                    return "Sound file (*.WAV)";
                }

                @Override
                public boolean accept(File file) 
                {
                    if (file.isDirectory()) 
                    {
                        return true;
                    } 
                    else 
                    {
                        return file.getName().toLowerCase().endsWith(".wav");
                    }
                }
            };

            fileChooser.setFileFilter(wavFilter);
            fileChooser.setDialogTitle("Open Audio File");
            fileChooser.setAcceptAllFileFilterUsed(false);

            int userChoice = fileChooser.showOpenDialog(this);
            if (userChoice == JFileChooser.APPROVE_OPTION) 
            {
                audioFilePath = fileChooser.getSelectedFile().getAbsolutePath();
                lastOpenPath = fileChooser.getSelectedFile().getParent();

                if (isPlaying || isPause) 
                {
                    stopPlaying();
                    while (player.getAudioClip().isRunning()) 
                    {
                        try 
                        {
                            Thread.sleep(100);
                        } 
                        catch (InterruptedException ex) 
                        {
                            ex.printStackTrace();
                        }
                    }
                }
                playBack();
            }
        }


        private void playBack() 
        {
            timer = new PlayingTimer(labelTimeCounter, sliderTime);

            timer.start();
            isPlaying = true;

            playbackThread = new Thread(new Runnable() 
            {
                @Override
                public void run() 
                {
                    try 
                    {
                        buttonPlay.setText("Stop");
                        buttonPlay.setEnabled(true);

                        buttonRewind.setEnabled(true);
                        buttonForward.setEnabled(true);

                        buttonPause.setText("Pause");
                        buttonPause.setEnabled(true);

                        player.load(audioFilePath);

                        timer.setAudioClip(player.getAudioClip());

                        labelFileName.setText("Playing File: " + ((File)new File(audioFilePath)).getName());

                        sliderTime.setMinimum(0);
                        sliderTime.setMaximum((int)player.getClipMicroSecondLength());

                        labelDuration.setText(player.getClipLengthString());

                        player.play();
                        labelFileName.setText("File Loaded: " + ((File)new File(audioFilePath)).getName());
                        resetControls();

                    } 
                    catch (UnsupportedAudioFileException ex) 
                    {
                        JOptionPane.showMessageDialog(
                            PlayerView.this,  
                            "The audio format is unsupported!", 
                            "Error", 
                            JOptionPane.ERROR_MESSAGE);
                        resetControls();
                    } 
                    catch (LineUnavailableException ex) 
                    {
                        JOptionPane.showMessageDialog(
                            PlayerView.this,  
                            "Could not play the audio file because line is unavailable!", 
                            "Error", 
                            JOptionPane.ERROR_MESSAGE);
                        resetControls();
                    } 
                    catch (IOException ex) 
                    {
                        JOptionPane.showMessageDialog(
                            PlayerView.this,  
                            "I/O error while playing the audio file!", 
                            "Error", 
                            JOptionPane.ERROR_MESSAGE);
                        resetControls();
                    }
                }
            });

            playbackThread.start();
        }

        private void stopPlaying() 
        {
            isPause = false;

            buttonPause.setText(" Pause ");
            buttonPause.setEnabled(false);
            buttonRewind.setEnabled(false);
            buttonForward.setEnabled(false);

            timer.reset();
            timer.interrupt();

            player.stop();
            playbackThread.interrupt();
        }

        private void pausePlaying() 
        {
            labelFileName.setText("File Loaded: " + ((File)new File(audioFilePath)).getName());
            buttonRewind.setEnabled(false);
            buttonForward.setEnabled(false);

            buttonPause.setText("Resume");
            isPause = true;

            player.pause();
            timer.pauseTimer();

            playbackThread.interrupt();
        }

        private void resumePlaying() 
        {
            labelFileName.setText("Playing File: " +  ((File)new File(audioFilePath)).getName());
            buttonPause.setText(" Pause ");
            buttonRewind.setEnabled(true);
            buttonForward.setEnabled(true);
            isPause = false;

            player.resume();
            timer.resumeTimer();

            playbackThread.interrupt();     
        }

        private void rewind() 
        {
            player.rewind();
            timer.rewind();
            timer.interrupt();
            playbackThread.interrupt(); 
        }

        private void forward() 
        {
            player.forward();
            timer.forward();
            timer.interrupt();
            playbackThread.interrupt(); 
        }

        private void resetControls() 
        {
            timer.reset();
            timer.interrupt();
            isPlaying = false;  

            buttonPlay.setText("Play");

            buttonPause.setEnabled(false);
            buttonRewind.setEnabled(false);
            buttonForward.setEnabled(false);    
        }

        private void init()
        {


            add(labelFileName, BorderLayout.NORTH);
            add(labelTimeCounter, BorderLayout.WEST);
            add(labelDuration, BorderLayout.EAST);
            add(sliderTime, BorderLayout.CENTER);

            JPanel buttonContainer =new JPanel();
            add(buttonContainer, BorderLayout.SOUTH);

            buttonContainer.add(buttonOpen);
            buttonContainer.add(buttonPlay);
            buttonContainer.add(buttonPause);
            buttonContainer.add(buttonRewind);
            buttonContainer.add(buttonForward);

        }
}

Solution

  • Okay, so, the issue with Clip. Here is an MCVE that, from the way you've described the problem, may reproduce it:

    class TestFramePosition {
        public static void main(String[] a) throws Exception {
            File file = new File(a.length > 0 ? a[0] : "path/to/file.extension");
            AudioInputStream ais = AudioSystem.getAudioInputStream(file);
            final Clip clip = AudioSystem.getClip();
    
            clip.open(ais);
            clip.start();
    
            new Thread(new Runnable() {
                @Override
                public void run() {
                    while(clip.isRunning()) {
                        try {
                            System.out.println(clip.getMicrosecondPosition());
                            Thread.sleep(1000 / 10);
                        } catch(InterruptedException ignored) {}
                    }
                }
            }).start();
    
            System.in.read();
            System.exit(0);
        }
    }
    

    I was unable to reproduce it on OSX 10.6.8 and Windows XP, but you may run that code to see if it does on your particular platform.

    So, the issue here is that, as I said in comments, since sound playback is dependent on platform-specific stuff, classes like Clip will have varied implementations. These will behave slightly differently.

    For example, I found that when a Clip is done playing, the Clip on my Mac computer (a com.sun.media.sound.MixerClip) returns 0 for the position, while the Clip on my Windows computer (a com.sun.media.sound.DirectAudioDevice$DirectClip) returns the maximum value for the position. Just another small example of implementations being programmed differently.

    The issue is that the contract for these methods is defined a little vaguely but, specifically, it is defined by 'the number of sample frames captured by, or rendered from, the line since it was opened'. This means it may not accurately represent the playback position, rather it is the amount of data read and written.

    I did spend awhile yesterday perusing JDK source code but I was unable to find anything that would point towards the behavior you are seeing.

    Anyway, what it comes down to is whether you are OK with slightly anomalous behavioral differences from platform to platform. What you are seeing may be a bug and if the above MCVE reproduces it, you may report it; however I would not personally expect it to get fixed in any timely manner because this is a section of the JDK that does not get a lot of attention. Also it is gradually being superseded by JavaFX.

    Some other things:

    • You are sharing state between threads without synchronization. This leads to memory errors. You should read the concurrency tutorials, specifically synchronization.
    • You should always cap frame rate when working with Swing. Swing will not paint at 1000FPS, it will merge repaints aggressively. Updating the slider at this rate was just flooding the EDT.

    You may use SourceDataLine because it gives you much greater control over the buffering behavior. The downside is that you have to basically reimplement the functionality of Clip.

    Here is an MCVE demonstrating a playback loop to power a JSlider.

    PlaybackSlider

    This example doesn't demonstrate seeking. Also since, AudioInputStream does not generally support mark operations, seeking backwards is a bit of a hassle. A backwards seek process is:

    • Stop the current playback and discard it.
    • Create a new AudioInputStream and seek forwards.
    • Start the new playback.

    Also, if you are planning to use the JSlider to seek, you will probably run in to an issue where calling setValue on a JSlider will cause it to fire a ChangeEvent. So you can't update the slider's value programmatically and also listen to it without rejiggering it. This is really a Q&A itself so if you experience this problem I recommend you ask a new question.

    import javax.sound.sampled.*;
    import javax.swing.*;
    import java.awt.event.*;
    
    import java.awt.Dimension;
    import java.awt.BorderLayout;
    import java.io.File;
    import java.io.IOException;
    
    public class PlaybackSlider implements Runnable, ActionListener {
        public static void main(String[] args) {
            SwingUtilities.invokeLater(new PlaybackSlider());
        }
    
        JButton open;
        JButton play;
        JSlider slider;
        JLabel label;
    
        File file;
        PlaybackLoop player;
    
        @Override
        public void run() {
            JFrame frame = new JFrame("Playback Slider");
            frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    
            JPanel content = new JPanel(new BorderLayout()) {
                @Override
                public Dimension getPreferredSize() {
                    Dimension pref = super.getPreferredSize();
                    pref.width = 480;
                    return pref;
                }
            };
    
            slider = new JSlider(JSlider.HORIZONTAL, 0, 1000, 0);
            content.add(slider, BorderLayout.CENTER);
    
            JToolBar bar = new JToolBar(JToolBar.HORIZONTAL);
            bar.setFloatable(false);
            content.add(bar, BorderLayout.SOUTH);
    
            open = new JButton("Open");
            play = new JButton("Play");
    
            open.addActionListener(this);
            play.addActionListener(this);
    
            label = new JLabel("");
    
            bar.add(open);
            bar.add(new JLabel(" "));
            bar.add(play);
            bar.add(new JLabel(" "));
            bar.add(label);
    
            frame.setContentPane(content);
            frame.pack();
            frame.setResizable(false);
            frame.setLocationRelativeTo(null);
            frame.setVisible(true);
        }
    
        @Override
        public void actionPerformed(ActionEvent ae) {
            Object source = ae.getSource();
    
            if(source == open) {
                File f = getFile();
                if(f != null) {
                    file = f;
                    label.setText(file.getName());
                    stop();
                }
            }
    
            if(source == play) {
                if(file != null) {
                    if(player != null) {
                        stop();
                    } else {
                        start();
                    }
                }
            }
        }
    
        File getFile() {
            JFileChooser diag = new JFileChooser();
            int choice = diag.showOpenDialog(null);
    
            if(choice == JFileChooser.APPROVE_OPTION) {
                return diag.getSelectedFile();
            } else {
                return null;
            }
        }
    
        void start() {
            try {
                player = new PlaybackLoop(file);
                new Thread(player).start();
                play.setText("Stop");
            } catch(Exception e) {
                player = null;
                showError("the file couldn't be played", e);
            }
        }
    
        void stop() {
            if(player != null) {
                player.stop();
            }
        }
    
        void showError(String msg, Throwable cause) {
            JOptionPane.showMessageDialog(null,
                "There was an error because " + msg +
                (cause == null ? "." : "\n(" + cause + ").")
            );
        }
    
        class PlaybackLoop implements Runnable {
            AudioInputStream in;
            SourceDataLine line;
            AudioFormat fmt;
            int bufferSize;
    
            boolean stopped;
    
            PlaybackLoop(File file) throws Exception {
                try {
                    in = AudioSystem.getAudioInputStream(file);
                    fmt = in.getFormat();
    
                    bufferSize = (int)(fmt.getFrameSize() * (fmt.getSampleRate() / 15));
    
                    line = AudioSystem.getSourceDataLine(fmt);
                    line.open(fmt, bufferSize);
                } catch(Exception e) {
                    if(in != null)
                        in.close();
                    if(line != null)
                        line.close();
                    throw e;
                }
            }
    
            void stop() {
                synchronized(this) {
                    this.stopped = true;
                }
            }
    
            @Override
            public void run() {
                line.start();
                byte[] buf = new byte[bufferSize];
    
                try {
                    try {
                        int b;
                        long elapsed = 0;
                        long total = in.getFrameLength();
    
                        for(;;) {
                            synchronized(this) {
                                if(stopped) {
                                    break;
                                }
                            }
    
                            b = in.read(buf, 0, buf.length);
                            if(b < 0) {
                                break;
                            }
    
                            elapsed += b / fmt.getFrameSize();
                            updateSlider(elapsed, total);
    
                            line.write(buf, 0, b);
                        }
                    } finally {
                        line.close();
                        in.close();
                    }
                } catch(IOException e) {
                    e.printStackTrace(System.err);
                    showError("there was a problem during playback", e);
                }
    
                endOnEDT();
            }
    
            void updateSlider(double elapsed, double total) {
                final double amt = elapsed / total;
                SwingUtilities.invokeLater(new Runnable() {
                    @Override
                    public void run() {
                        slider.setValue((int)Math.round(slider.getMaximum() * amt));
                    }
                });
            }
    
            void endOnEDT() {
                SwingUtilities.invokeLater(new Runnable() {
                    @Override
                    public void run() {
                        player = null;
                        slider.setValue(0);
                        play.setText("Play");
                    }
                });
            }
        }
    }