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}