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