001package jmri.jmrix.dccpp.swing.messages;
002
003import java.awt.*;
004import java.io.*;
005import java.net.URL;
006import java.text.DateFormat;
007import java.text.SimpleDateFormat;
008import java.util.*;
009import java.util.List;
010import java.util.function.Consumer;
011import java.util.function.LongSupplier;
012import java.util.function.Predicate;
013
014import javax.annotation.CheckForNull;
015import javax.sound.sampled.AudioInputStream;
016import javax.sound.sampled.AudioSystem;
017import javax.sound.sampled.Clip;
018import javax.sound.sampled.LineUnavailableException;
019import javax.sound.sampled.UnsupportedAudioFileException;
020import javax.swing.*;
021
022import jmri.InvokeOnGuiThread;
023import jmri.jmrit.Sound;
024import jmri.jmrit.display.Positionable;
025import jmri.jmrix.dccpp.*;
026import jmri.util.FileUtil;
027import jmri.util.JmriJFrame;
028
029/** Panel showing EXRAIL MESSAGE() broadcasts from a DCC-EX command station, styled like the Virtual LCD. */
030public class MessagesPanel extends JPanel implements DCCppListener, MessagesConfiguration {
031
032    public static final String DEFAULT_SOUND_FILE = "program:resources/sounds/bell_stroke.wav";
033
034    private final boolean _isMemoEditable;
035    private final JmriJFrame _frame;
036    private final Positionable _positionable;
037    private final MessageQueue _queue;
038    private final javax.swing.Timer _timer;
039    private final List<JLabel> _lines = new ArrayList<>();
040    private final Map<String, Boolean> _checkedSoundFiles = new HashMap<>();
041    private Predicate<URL> _soundCheck = MessagesPanel::isPlayable;
042    private Consumer<URL> _soundPlayer = url -> new Sound(url).play(true);
043    private final DateFormat _timeFormat = new SimpleDateFormat("HH:mm:ss");
044    private DCCppTrafficController _tc;
045    private DCCppSystemConnectionMemo _memo;
046    private Font _font;
047    private boolean _autoOpen = true;
048    private boolean _playSound = false;
049    private String _soundFile = DEFAULT_SOUND_FILE;
050    private Color _foreground = Color.WHITE;
051    private Color _background = Color.BLACK;
052    private Dimension _messagesSize;
053
054    public MessagesPanel(@CheckForNull JmriJFrame frame, boolean isMemoEditable) {
055        this(frame, null, isMemoEditable);
056    }
057
058    public MessagesPanel(@CheckForNull JmriJFrame frame, @CheckForNull Positionable pos, boolean isMemoEditable) {
059        this(frame, pos, isMemoEditable, System::currentTimeMillis);
060    }
061
062    MessagesPanel(@CheckForNull JmriJFrame frame, @CheckForNull Positionable pos, boolean isMemoEditable,
063            LongSupplier clock) {
064        _isMemoEditable = isMemoEditable;
065        _frame = frame;
066        _positionable = pos;
067        _queue = new MessageQueue(clock);
068        _timer = new javax.swing.Timer(1000, e -> tick());
069    }
070
071    public void initComponents() {
072        setLayout(new BoxLayout(this, BoxLayout.Y_AXIS));
073
074        // same 5x8 font as the Virtual LCD
075        try {
076            InputStream stream = new FileInputStream(new File("resources/fonts/5x8_lcd_hd44780u_a02.ttf"));
077            _font = Font.createFont(Font.TRUETYPE_FONT, stream).deriveFont(16f).deriveFont(Font.BOLD);
078        } catch (IOException e1) { log.error("failed to find or open font file");
079        } catch (FontFormatException e2) { log.error("font file not valid");
080        }
081
082        setOpaque(true);
083        rebuild();
084    }
085
086    public void dispose() {
087        _timer.stop();
088        if (_tc != null) {
089            _tc.removeDCCppListener(DCCppInterface.CS_INFO, this);
090        }
091    }
092
093    @InvokeOnGuiThread
094    public void addMessage(String text) {
095        _queue.add(text);
096        rebuild();
097        if (!_timer.isRunning() && _queue.getExpirySeconds() > 0) {
098            _timer.start();
099        }
100        playSound();
101    }
102
103    @InvokeOnGuiThread
104    public void clearMessages() {
105        _queue.clear();
106        _timer.stop();
107        rebuild();
108    }
109
110    @InvokeOnGuiThread
111    void tick() {
112        if (_queue.expire()) {
113            rebuild();
114        }
115        if (_queue.getEntries().isEmpty()) {
116            _timer.stop();
117        }
118    }
119
120    boolean isExpiryTimerRunning() {
121        return _timer.isRunning();
122    }
123
124    public int getLineCount() {
125        return _lines.size();
126    }
127
128    public String getLineText(int index) {
129        return _lines.get(index).getText();
130    }
131
132    public String getNameString() {
133        return Bundle.getMessage("MessagesPositionable", _memo != null ? _memo.getSystemPrefix() : "");
134    }
135
136    private void playSound() {
137        if (!_playSound) {
138            return;
139        }
140        URL url = FileUtil.findURL(_soundFile);
141        Boolean playable = _checkedSoundFiles.get(_soundFile);
142        if (playable == null) {
143            playable = url != null && _soundCheck.test(url);
144            _checkedSoundFiles.put(_soundFile, playable);
145            if (!playable) {
146                log.warn("Unable to play DCC-EX message sound {}", _soundFile);
147            }
148        }
149        if (playable) {
150            _soundPlayer.accept(url);
151        }
152    }
153
154    // tests swap these to count checks and plays without real audio
155    void setSoundHooks(Predicate<URL> check, Consumer<URL> player) {
156        _soundCheck = check;
157        _soundPlayer = player;
158    }
159
160    // Sound logs its own errors on every play, so check the file and audio line once up front
161    private static boolean isPlayable(URL url) {
162        try (AudioInputStream stream = AudioSystem.getAudioInputStream(url); Clip clip = AudioSystem.getClip(null)) {
163            clip.open(stream);
164            return true;
165        } catch (UnsupportedAudioFileException | IOException | LineUnavailableException | IllegalArgumentException e) {
166            return false;
167        }
168    }
169
170    private void rebuild() {
171        removeAll();
172        _lines.clear();
173
174        List<String> texts = new ArrayList<>();
175        List<MessageQueue.Entry> entries = _queue.getEntries();
176        if (entries.isEmpty()) {
177            texts.add(Bundle.getMessage("MessagesNoMessages"));
178        } else {
179            for (MessageQueue.Entry e : entries) {
180                texts.add(_timeFormat.format(new Date(e.getTimeMillis())) + "  " + e.getText());
181            }
182        }
183        if (_messagesSize != null) {
184            while (texts.size() > _messagesSize.height) {
185                texts.remove(texts.size() - 1);
186            }
187            while (texts.size() < _messagesSize.height) {
188                texts.add("");
189            }
190        }
191
192        for (String text : texts) {
193            JLabel label = new JLabel(cutIfNeeded(text));
194            if (_font != null) label.setFont(_font);
195            label.setOpaque(true);
196            label.setBackground(_background);
197            label.setForeground(_foreground);
198            _lines.add(label);
199            add(label);
200        }
201        setBackground(_background);
202        packOrResize();
203        repaint();
204    }
205
206    private String cutIfNeeded(String s) {
207        if (_messagesSize != null) {
208            StringBuilder sb = new StringBuilder(s);
209            while (sb.length() < _messagesSize.width) {
210                sb.append(' ');
211            }
212            if (sb.length() > _messagesSize.width) {
213                sb.setLength(_messagesSize.width);
214            }
215            return sb.toString();
216        }
217        return s;
218    }
219
220    private void packOrResize() {
221        if (_positionable != null) {
222            var d = this.getPreferredSize();
223            this.setSize(d);
224            _positionable.setSize(d);
225            // rebuild adds fresh labels and nothing else on a panel lays them out
226            doLayout();
227        } else if (_frame != null) {
228            _frame.pack();
229        } else {
230            revalidate();
231        }
232    }
233
234    @Override
235    public void setMemo(DCCppSystemConnectionMemo memo) {
236        if (_tc != null) {
237            _tc.removeDCCppListener(DCCppInterface.CS_INFO, this);
238        }
239        _memo = memo;
240        _tc = memo.getDCCppTrafficController();
241        _tc.addDCCppListener(DCCppInterface.CS_INFO, this);
242    }
243
244    @Override
245    public DCCppSystemConnectionMemo getMemo() {
246        return _memo;
247    }
248
249    @Override
250    public boolean isMemoEditable() {
251        return _isMemoEditable;
252    }
253
254    @Override
255    public void setExpirySeconds(int seconds) {
256        _queue.setExpirySeconds(seconds);
257        if (_queue.getExpirySeconds() == 0) {
258            _timer.stop();
259        } else if (!_queue.getEntries().isEmpty()) {
260            _timer.start();
261        }
262    }
263
264    @Override
265    public int getExpirySeconds() {
266        return _queue.getExpirySeconds();
267    }
268
269    @Override
270    public void setMaxLines(int lines) {
271        _queue.setMaxLines(lines);
272        rebuild();
273    }
274
275    @Override
276    public int getMaxLines() {
277        return _queue.getMaxLines();
278    }
279
280    @Override
281    public void setAutoOpen(boolean autoOpen) {
282        _autoOpen = autoOpen;
283    }
284
285    @Override
286    public boolean isAutoOpen() {
287        return _autoOpen;
288    }
289
290    @Override
291    public void setPlaySound(boolean playSound) {
292        _playSound = playSound;
293    }
294
295    @Override
296    public boolean isPlaySound() {
297        return _playSound;
298    }
299
300    @Override
301    public void setSoundFile(String path) {
302        _soundFile = path;
303    }
304
305    @Override
306    public String getSoundFile() {
307        return _soundFile;
308    }
309
310    @Override
311    public void setMessageForeground(Color color) {
312        _foreground = color;
313        rebuild();
314    }
315
316    @Override
317    public Color getMessageForeground() {
318        return _foreground;
319    }
320
321    @Override
322    public void setMessageBackground(Color color) {
323        _background = color;
324        rebuild();
325    }
326
327    @Override
328    public Color getMessageBackground() {
329        return _background;
330    }
331
332    @Override
333    public void setMessagesSize(@CheckForNull Dimension size) {
334        if (size != null && size.width > 0 && size.height > 0) {
335            _messagesSize = size;
336        } else {
337            _messagesSize = null;
338        }
339        rebuild();
340    }
341
342    @CheckForNull
343    @Override
344    public Dimension getMessagesSize() {
345        return _messagesSize;
346    }
347
348    /** {@inheritDoc} */
349    @Override
350    public void message(DCCppReply msg) {
351        if (msg.isMessageReply()) {
352            addMessage(msg.getMessageString());
353        }
354    }
355
356    /** {@inheritDoc} */
357    @Override
358    public void message(DCCppMessage msg) {
359    }
360
361    /** {@inheritDoc} */
362    @Override
363    public void notifyTimeout(DCCppMessage msg) {
364    }
365
366    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(MessagesPanel.class);
367
368}