001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.gui.layer.markerlayer; 003 004import static org.openstreetmap.josm.tools.I18n.tr; 005 006import java.awt.Graphics; 007import java.awt.Point; 008import java.awt.Rectangle; 009import java.awt.event.MouseAdapter; 010import java.awt.event.MouseEvent; 011import java.io.IOException; 012 013import javax.swing.JOptionPane; 014import javax.swing.Timer; 015 016import org.openstreetmap.josm.actions.mapmode.MapMode; 017import org.openstreetmap.josm.actions.mapmode.PlayHeadDragMode; 018import org.openstreetmap.josm.data.coor.EastNorth; 019import org.openstreetmap.josm.data.coor.LatLon; 020import org.openstreetmap.josm.data.gpx.GpxTrack; 021import org.openstreetmap.josm.data.gpx.GpxTrackSegment; 022import org.openstreetmap.josm.data.gpx.WayPoint; 023import org.openstreetmap.josm.data.projection.ProjectionRegistry; 024import org.openstreetmap.josm.gui.MainApplication; 025import org.openstreetmap.josm.gui.MapFrame; 026import org.openstreetmap.josm.gui.MapView; 027import org.openstreetmap.josm.gui.layer.GpxLayer; 028import org.openstreetmap.josm.io.audio.AudioPlayer; 029import org.openstreetmap.josm.io.audio.AudioUtil; 030import org.openstreetmap.josm.spi.preferences.Config; 031 032/** 033 * Singleton marker class to track position of audio. 034 * 035 * @author David Earl <david@frankieandshadow.com> 036 * @since 572 037 */ 038public final class PlayHeadMarker extends Marker { 039 040 private Timer timer; 041 private double animationInterval; // seconds 042 private static volatile PlayHeadMarker playHead; 043 private MapMode oldMode; 044 private LatLon oldCoor; 045 private final boolean enabled; 046 private boolean wasPlaying; 047 private int dropTolerance; /* pixels */ 048 private boolean jumpToMarker; 049 050 /** 051 * Returns the unique instance of {@code PlayHeadMarker}. 052 * @return The unique instance of {@code PlayHeadMarker}. 053 */ 054 public static PlayHeadMarker create() { 055 if (playHead == null) { 056 playHead = new PlayHeadMarker(); 057 } 058 return playHead; 059 } 060 061 private PlayHeadMarker() { 062 super(LatLon.ZERO, "", 063 Config.getPref().get("marker.audiotracericon", /* ICON(markers/) */ "audio-tracer"), 064 null, -1.0, 0.0); 065 enabled = Config.getPref().getBoolean("marker.traceaudio", true); 066 if (!enabled) return; 067 dropTolerance = Config.getPref().getInt("marker.playHeadDropTolerance", 50); 068 if (MainApplication.isDisplayingMapView()) { 069 MapFrame map = MainApplication.getMap(); 070 map.mapView.addMouseListener(new MouseAdapter() { 071 @Override public void mousePressed(MouseEvent ev) { 072 if (ev.getButton() == MouseEvent.BUTTON1 && playHead.containsPoint(ev.getPoint())) { 073 /* when we get a click on the marker, we need to switch mode to avoid 074 * getting confused with other drag operations (like select) */ 075 oldMode = map.mapMode; 076 oldCoor = getCoor(); 077 PlayHeadDragMode playHeadDragMode = new PlayHeadDragMode(playHead); 078 map.selectMapMode(playHeadDragMode); 079 playHeadDragMode.mousePressed(ev); 080 } 081 } 082 }); 083 } 084 } 085 086 @Override 087 public boolean containsPoint(Point p) { 088 Point screen = MainApplication.getMap().mapView.getPoint(this); 089 Rectangle r = new Rectangle(screen.x, screen.y, symbol.getIconWidth(), 090 symbol.getIconHeight()); 091 return r.contains(p); 092 } 093 094 /** 095 * called back from drag mode to say when we started dragging for real 096 * (at least a short distance) 097 */ 098 public void startDrag() { 099 if (timer != null) { 100 timer.stop(); 101 } 102 wasPlaying = AudioPlayer.playing(); 103 if (wasPlaying) { 104 try { 105 AudioPlayer.pause(); 106 } catch (IOException | InterruptedException ex) { 107 AudioUtil.audioMalfunction(ex); 108 } 109 } 110 } 111 112 /** 113 * reinstate the old map mode after switching temporarily to do a play head drag 114 * @param reset whether to reset state (pause audio and restore old coordinates) 115 */ 116 private void endDrag(boolean reset) { 117 if (!wasPlaying || reset) { 118 try { 119 AudioPlayer.pause(); 120 } catch (IOException | InterruptedException ex) { 121 AudioUtil.audioMalfunction(ex); 122 } 123 } 124 if (reset) { 125 setCoor(oldCoor); 126 } 127 MapFrame map = MainApplication.getMap(); 128 map.selectMapMode(oldMode); 129 map.mapView.repaint(); 130 if (timer != null) { 131 timer.start(); 132 } 133 } 134 135 /** 136 * apply the new position resulting from a drag in progress 137 * @param en the new position in map terms 138 */ 139 public void drag(EastNorth en) { 140 setEastNorth(en); 141 MainApplication.getMap().mapView.repaint(); 142 } 143 144 /** 145 * reposition the play head at the point on the track nearest position given, 146 * providing we are within reasonable distance from the track; otherwise reset to the 147 * original position. 148 * @param en the position to start looking from 149 */ 150 public void reposition(EastNorth en) { 151 WayPoint cw = null; 152 AudioMarker recent = AudioMarker.recentlyPlayedMarker(); 153 if (recent != null && recent.parentLayer != null && recent.parentLayer.fromLayer != null) { 154 /* work out EastNorth equivalent of 50 (default) pixels tolerance */ 155 MapView mapView = MainApplication.getMap().mapView; 156 Point p = mapView.getPoint(en); 157 EastNorth enPlus25px = mapView.getEastNorth(p.x+dropTolerance, p.y); 158 cw = recent.parentLayer.fromLayer.data.nearestPointOnTrack(en, enPlus25px.east() - en.east()); 159 } 160 161 AudioMarker ca = null; 162 /* Find the prior audio marker (there should always be one in the 163 * layer, even if it is only one at the start of the track) to 164 * offset the audio from */ 165 if (cw != null && recent != null && recent.parentLayer != null) { 166 for (Marker m : recent.parentLayer.data) { 167 if (m instanceof AudioMarker) { 168 AudioMarker a = (AudioMarker) m; 169 if (a.time > cw.getTime()) { 170 break; 171 } 172 ca = a; 173 } 174 } 175 } 176 177 if (ca == null) { 178 /* Not close enough to track, or no audio marker found for some other reason */ 179 JOptionPane.showMessageDialog( 180 MainApplication.getMainFrame(), 181 tr("You need to drag the play head near to the GPX track " + 182 "whose associated sound track you were playing (after the first marker)."), 183 tr("Warning"), 184 JOptionPane.WARNING_MESSAGE 185 ); 186 endDrag(true); 187 } else { 188 if (cw != null) { 189 setCoor(cw.getCoor()); 190 ca.play(cw.getTime() - ca.time); 191 } 192 endDrag(false); 193 } 194 } 195 196 /** 197 * Synchronize the audio at the position where the play head was paused before 198 * dragging with the position on the track where it was dropped. 199 * If this is quite near an audio marker, we use that 200 * marker as the sync. location, otherwise we create a new marker at the 201 * trackpoint nearest the end point of the drag point to apply the 202 * sync to. 203 * @param en : the EastNorth end point of the drag 204 */ 205 public void synchronize(EastNorth en) { 206 AudioMarker recent = AudioMarker.recentlyPlayedMarker(); 207 if (recent == null) 208 return; 209 /* First, see if we dropped onto an existing audio marker in the layer being played */ 210 MapView mapView = MainApplication.getMap().mapView; 211 Point startPoint = mapView.getPoint(en); 212 AudioMarker ca = null; 213 if (recent.parentLayer != null) { 214 double closestAudioMarkerDistanceSquared = 1.0E100; 215 for (Marker m : recent.parentLayer.data) { 216 if (m instanceof AudioMarker) { 217 double distanceSquared = m.getEastNorth(ProjectionRegistry.getProjection()).distanceSq(en); 218 if (distanceSquared < closestAudioMarkerDistanceSquared) { 219 ca = (AudioMarker) m; 220 closestAudioMarkerDistanceSquared = distanceSquared; 221 } 222 } 223 } 224 } 225 226 /* We found the closest marker: did we actually hit it? */ 227 if (ca != null && !ca.containsPoint(startPoint)) { 228 ca = null; 229 } 230 231 /* If we didn't hit an audio marker, we need to create one at the nearest point on the track */ 232 if (ca == null) { 233 /* work out EastNorth equivalent of 50 (default) pixels tolerance */ 234 Point p = mapView.getPoint(en); 235 EastNorth enPlus25px = mapView.getEastNorth(p.x+dropTolerance, p.y); 236 WayPoint cw = recent.parentLayer.fromLayer.data.nearestPointOnTrack(en, enPlus25px.east() - en.east()); 237 if (cw == null) { 238 JOptionPane.showMessageDialog( 239 MainApplication.getMainFrame(), 240 tr("You need to SHIFT-drag the play head onto an audio marker or onto the track point where you want to synchronize."), 241 tr("Warning"), 242 JOptionPane.WARNING_MESSAGE 243 ); 244 endDrag(true); 245 return; 246 } 247 ca = recent.parentLayer.addAudioMarker(cw.getTime(), cw.getCoor()); 248 } 249 250 /* Actually do the synchronization */ 251 if (ca == null) { 252 JOptionPane.showMessageDialog( 253 MainApplication.getMainFrame(), 254 tr("Unable to create new audio marker."), 255 tr("Error"), 256 JOptionPane.ERROR_MESSAGE 257 ); 258 endDrag(true); 259 } else if (recent.parentLayer.synchronizeAudioMarkers(ca)) { 260 JOptionPane.showMessageDialog( 261 MainApplication.getMainFrame(), 262 tr("Audio synchronized at point {0}.", recent.parentLayer.syncAudioMarker.getText()), 263 tr("Information"), 264 JOptionPane.INFORMATION_MESSAGE 265 ); 266 setCoor(recent.parentLayer.syncAudioMarker.getCoor()); 267 endDrag(false); 268 } else { 269 JOptionPane.showMessageDialog( 270 MainApplication.getMainFrame(), 271 tr("Unable to synchronize in layer being played."), 272 tr("Error"), 273 JOptionPane.ERROR_MESSAGE 274 ); 275 endDrag(true); 276 } 277 } 278 279 /** 280 * Paint the marker icon in the given graphics context. 281 * @param g The graphics context 282 * @param mv The map 283 */ 284 public void paint(Graphics g, MapView mv) { 285 if (time < 0.0) return; 286 Point screen = mv.getPoint(this); 287 paintIcon(mv, g, screen.x, screen.y); 288 } 289 290 /** 291 * Animates the marker along the track. 292 */ 293 public void animate() { 294 if (!enabled) return; 295 jumpToMarker = true; 296 if (timer == null) { 297 animationInterval = Config.getPref().getDouble("marker.audioanimationinterval", 1.0); //milliseconds 298 timer = new Timer((int) (animationInterval * 1000.0), e -> timerAction()); 299 timer.setInitialDelay(0); 300 } else { 301 timer.stop(); 302 } 303 timer.start(); 304 } 305 306 /** 307 * callback for moving play head marker according to audio player position 308 */ 309 public void timerAction() { 310 AudioMarker recentlyPlayedMarker = AudioMarker.recentlyPlayedMarker(); 311 if (recentlyPlayedMarker == null) 312 return; 313 double audioTime = recentlyPlayedMarker.time + 314 AudioPlayer.position() - 315 recentlyPlayedMarker.offset - 316 recentlyPlayedMarker.syncOffset; 317 if (Math.abs(audioTime - time) < animationInterval) 318 return; 319 if (recentlyPlayedMarker.parentLayer == null) return; 320 GpxLayer trackLayer = recentlyPlayedMarker.parentLayer.fromLayer; 321 if (trackLayer == null) 322 return; 323 /* find the pair of track points for this position (adjusted by the syncOffset) 324 * and interpolate between them 325 */ 326 WayPoint w1 = null; 327 WayPoint w2 = null; 328 329 for (GpxTrack track : trackLayer.data.getTracks()) { 330 for (GpxTrackSegment trackseg : track.getSegments()) { 331 for (WayPoint w: trackseg.getWayPoints()) { 332 if (audioTime < w.getTime()) { 333 w2 = w; 334 break; 335 } 336 w1 = w; 337 } 338 if (w2 != null) { 339 break; 340 } 341 } 342 if (w2 != null) { 343 break; 344 } 345 } 346 347 if (w1 == null) 348 return; 349 setEastNorth(w2 == null ? 350 w1.getEastNorth(ProjectionRegistry.getProjection()) : 351 w1.getEastNorth(ProjectionRegistry.getProjection()).interpolate(w2.getEastNorth(ProjectionRegistry.getProjection()), 352 (audioTime - w1.getTime())/(w2.getTime() - w1.getTime()))); 353 time = audioTime; 354 MapView mapView = MainApplication.getMap().mapView; 355 if (jumpToMarker) { 356 jumpToMarker = false; 357 mapView.zoomTo(w1); 358 } 359 mapView.repaint(); 360 } 361}