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