/* * Jajuk * Copyright (C) The Jajuk Team * http://jajuk.info * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * */ package org.jajuk.services.players; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.util.Map; import java.util.StringTokenizer; import java.util.regex.Pattern; import org.jajuk.base.Track; import org.jajuk.events.JajukEvent; import org.jajuk.events.JajukEvents; import org.jajuk.events.ObservationManager; import org.jajuk.services.webradio.WebRadio; import org.jajuk.ui.actions.ActionManager; import org.jajuk.ui.actions.JajukActions; import org.jajuk.util.Conf; import org.jajuk.util.Const; import org.jajuk.util.UtilFeatures; import org.jajuk.util.UtilSystem; import org.jajuk.util.error.JajukException; import org.jajuk.util.log.Log; /** * Jajuk player implementation based on Mplayer. */ public class MPlayerPlayerImpl extends AbstractMPlayerImpl { /** Time elapsed in ms. */ private long lTime = 0; /** Actually played time */ private long actuallyPlayedTimeMillis = 0l; private long lastPlayTimeUpdate = System.currentTimeMillis(); /** Length to be played in secs. */ private long length; /** Starting position. */ private float fPosition; /** Current track estimated total duration in ms. */ private long lDuration; /** Volume when starting fade. */ private float fadingVolume; /** Cross fade duration in ms. */ int iFadeDuration = 0; /** Time track started *. */ private long dateStart; /** Pause time correction *. */ private long pauseCount = 0; private long pauseCountStamp = -1; /** Is the play is in error. */ private boolean bInError = false; /** Progress step in ms, do not set less than 300 or 400 to avoid using too much CPU. */ private static final int PROGRESS_STEP = 500; /** Total play time is refreshed every TOTAL_PLAYTIME_UPDATE_INTERVAL times. */ private static final int TOTAL_PLAYTIME_UPDATE_INTERVAL = 2; /** Current file. */ private org.jajuk.base.File fCurrent; /** [Windows only] Force use of shortnames. */ private boolean bForcedShortnames = false; /** English-specific end of file pattern */ private Pattern patternEndOfFileEnglish = Pattern .compile("Exiting\\x2e\\x2e\\x2e.*\\(End of file\\)"); /** Language-agnostic end of file pattern */ private Pattern patternEndOfFileGeneric = Pattern.compile(".*\\x2e\\x2e\\x2e.*\\(.*\\)"); /** * Position and elapsed time getter. */ private class PositionThread extends Thread { /** * Instantiates a new position thread. * * @param name */ public PositionThread(String name) { super(name); setDaemon(true); } /* * (non-Javadoc) * * @see java.lang.Thread#run() */ @Override public void run() { int comp = 0; lastPlayTimeUpdate = System.currentTimeMillis(); Track current = fCurrent.getTrack(); while (!bStop && !bEOF) { // stop this thread try { // store elapsed time while the track is paused if (pauseCountStamp > 0) { pauseCount += (System.currentTimeMillis() - pauseCountStamp); pauseCountStamp = -1; } if (bPaused) { pauseCountStamp = System.currentTimeMillis(); } if (!bPaused) { // Do not call a get_percent_pos if paused, it resumes the player // (mplayer issue) sendCommand("get_time_pos"); // Get track length if required. Do not launch "get_time_length" only // once because some fast computer makes mplayer start too fast and // the slave mode is not yet opened so this command is not token into account. // See bug #1816 (Track length is zero after a restart) if (lDuration == 0) { sendCommand("get_time_length"); } // Every 2 time units, increase actual play time. We wait this // delay for perfs and for precision if (comp > 0 && comp % TOTAL_PLAYTIME_UPDATE_INTERVAL == 0) { // Increase actual play time // End of file: increase actual play time to the track // Perf note : this full action takes less much than 1 ms long trackPlaytime = current.getLongValue(Const.XML_TRACK_TOTAL_PLAYTIME); long newValue = (PROGRESS_STEP * TOTAL_PLAYTIME_UPDATE_INTERVAL / 1000) + trackPlaytime; current.setProperty(Const.XML_TRACK_TOTAL_PLAYTIME, newValue); } } comp++; Thread.sleep(PROGRESS_STEP); } catch (Exception e) { Log.error(e); } } } } /** * Reader : read information from mplayer like position. */ private class ReaderThread extends Thread { /** * Instantiates a new reader thread. * * @param name */ public ReaderThread(String name) { super(name); setDaemon(true); } /* * (non-Javadoc) * * @see java.lang.Thread#run() */ @Override public void run() { try { BufferedReader in = new BufferedReader(new InputStreamReader(proc.getInputStream())); // While we don't know the mplayer language, patternEndOfFile matches any language end of file pattern : .*... (.*) Pattern patternEndOfFile = patternEndOfFileGeneric; try { String line = null; while (!bStop) { try { line = in.readLine(); if (line == null) { break; } } catch (IOException ieo) { Log.debug("Stream closed"); // Thrown in readLine() when killing the track (in intro mode for // ie) break; } // Very verbose : //Log.debug("Output from MPlayer: " + line); // Detect mplayer language if (line.indexOf("Starting playback") != -1) { patternEndOfFile = patternEndOfFileEnglish; } else if (line.indexOf("ANS_TIME_POSITION") != -1) { // Stream is actually opened now bOpening = false; StringTokenizer st = new StringTokenizer(line, "="); st.nextToken(); try { lTime = (int) (Float.parseFloat(st.nextToken()) * 1000); } catch (NumberFormatException nfe) { Log.error(nfe); lTime = 0l; } pauseCount = 0; pauseCountStamp = -1; // update actually played duration if (lastPlayTimeUpdate > 0 && !bPaused) { actuallyPlayedTimeMillis += (System.currentTimeMillis() - lastPlayTimeUpdate); } lastPlayTimeUpdate = System.currentTimeMillis(); // Store current position for use at next startup UtilFeatures.storePersistedPlayingPosition(getCurrentPosition()); // Cross-Fade test if (!bFading && iFadeDuration > 0 // Length = 0 for some buggy audio headers && lDuration > 0 // Does fading time happened ? && lTime > (lDuration - iFadeDuration) // Do not fade if the track is very short && (lDuration > 3 * iFadeDuration) //Do not fade if bit perfect mode && !Conf.getBoolean(CONF_BIT_PERFECT)) { bFading = true; fadingVolume = fVolume; // Call finish (do not leave thread to allow cross fading) callFinish(); } // If fading, decrease sound progressively if (bFading) { // computes the volume we have to sub to reach zero // at last progress() float fVolumeStep = fadingVolume // we double the refresh period to make sure to // reach 0 at the end of iterations because // we don't as many mplayer response as queries, // tested on 10 & 20 sec of fading * ((float) PROGRESS_STEP / iFadeDuration); float fNewVolume = fVolume - fVolumeStep; // decrease volume by n% of initial volume if (fNewVolume < 0) { fNewVolume = 0; } try { setVolume(fNewVolume); } catch (Exception e) { Log.error(e); } } // Test end of length for intro mode // Length=-1 means there is no max length if (length != TO_THE_END // Duration = 0 in rare case due to header issue && lDuration > 0 // Is intro length fully played ? && (lTime - (fPosition * lDuration)) > length) { // No fading in intro mode bFading = false; // Call finish and terminate current thread callFinish(); return; } } else if (line.indexOf("ANS_LENGTH") != -1) { /* * To compute the current track length (used by the information panel to display * remaining time and position), we use the tag duration first and the mplayer * duration then if the tag duration looks wrong (example : wrongly tagged file or * format that doesn't support tags like wav). Indeed, mplayer duration is sometimes * wrong for VBR MP3. */ StringTokenizer st = new StringTokenizer(line, "="); st.nextToken(); long mplayerDuration = 0l; try { mplayerDuration = (long) (Float.parseFloat(st.nextToken()) * 1000); } catch (NumberFormatException nfe) { Log.error(nfe); } long tagDuration = fCurrent.getTrack().getDuration() * 1000; if (tagDuration <= 0) { lDuration = mplayerDuration; } else { lDuration = tagDuration; } } // End of file else if (patternEndOfFile.matcher(line).matches()) { bEOF = true; // Update track rate if it has been opened if (!bOpening) { fCurrent.getTrack().updateRate(); // Force immediate rating refresh (without using the rating manager) ObservationManager.notify(new JajukEvent(JajukEvents.RATE_CHANGED)); } // Launch next track try { // Do not launch next track if not opening: it means // that the file is in error (EOF comes // before any play) and the FIFO.finished() is processed by // Player on exception processing if (bOpening) { bOpening = false; bInError = true; break; } // If fading, ignore end of file if (!bFading) { // Call finish and terminate current thread callFinish(); return; } else { // If fading, next track has already been launched bFading = false; } } catch (Exception e) { Log.error(e); } break; } } } finally { // can reach this point at the end of file in.close(); } } catch (IOException e) { Log.error(e); } } } /* * (non-Javadoc) * * @see org.jajuk.services.players.AbstractMPlayerImpl#stop() */ @Override public void stop() throws Exception { // Call generic stop super.stop(); // Update track rate fCurrent.getTrack().updateRate(); // Force immediate rating refresh (without using the rating manager) ObservationManager.notify(new JajukEvent(JajukEvents.RATE_CHANGED)); } /* * (non-Javadoc) * * @see org.jajuk.players.IPlayerImpl#play(org.jajuk.base.File, float, long, float) */ @Override public void play(org.jajuk.base.File file, float fPosition, long length, float fVolume) throws IOException, JajukException { this.fVolume = fVolume; this.length = length; this.fPosition = fPosition; this.fCurrent = file; this.bitPerfect = Conf.getBoolean(Const.CONF_BIT_PERFECT); // Reset all states reset(); // Try to launch mplayer int startPos = (int) (fPosition * file.getTrack().getDuration()); launchMplayer(startPos); // If under windows and the launch failed, try once again // with other short names configuration (see #1267) if (bInError && UtilSystem.isUnderWindows()) { bForcedShortnames = true; Log.warn("Force shortname filename scheme" + " for : " + file.getAbsolutePath()); // Reset any state changed by the previous reader thread reset(); launchMplayer(startPos); // Disable forced shortnames because the shortnames converter takes a while (2 secs) bForcedShortnames = false; } // Check the file has been property opened if (bOpening || bEOF) { // try to kill the mplayer process if still alive if (proc != null) { Log.warn("OOT Mplayer process, try to kill it"); proc.destroy(); Log.warn("OK, the process should have been killed now"); } // Notify the problem opening the file throw new JajukException(7, Integer.valueOf(MPLAYER_START_TIMEOUT).toString() + " ms"); } } /** * Reset the player impl to initial state. */ private void reset() { this.lTime = 0; this.bFading = false; this.bInError = false; this.bStop = false; this.bOpening = true; this.bEOF = false; this.iFadeDuration = 1000 * Conf.getInt(Const.CONF_FADE_DURATION); this.dateStart = System.currentTimeMillis(); this.pauseCount = 0; this.pauseCountStamp = -1; } /** * Launch mplayer. * * @param startPositionSec the position in the track when starting in secs (0 means we plat from the begining) * @throws IOException Signals that an I/O exception has occurred. * */ private void launchMplayer(int startPositionSec) throws IOException { // Build the file url. Under windows, we convert path to short // version to fix a mplayer bug when reading some pathnames including // special characters (see #1267) String pathname = fCurrent.getAbsolutePath(); if (UtilSystem.isUnderWindows() && bForcedShortnames) { pathname = UtilSystem.getShortPathNameW(pathname); } ProcessBuilder pb = new ProcessBuilder(buildCommand(pathname, startPositionSec)); Log.debug("Using this Mplayer command: {{" + pb.command() + "}}"); // Set all environment variables format: var1=xxx var2=yyy try { Map<String, String> env = pb.environment(); StringTokenizer st = new StringTokenizer(Conf.getString(Const.CONF_ENV_VARIABLES), " "); while (st.hasMoreTokens()) { StringTokenizer st2 = new StringTokenizer(st.nextToken(), "="); env.put(st2.nextToken(), st2.nextToken()); } } catch (Exception e) { Log.error(e); } // Start mplayer proc = pb.start(); // start mplayer replies reader thread new ReaderThread("MPlayer reader thread").start(); // start writer to mplayer thread new PositionThread("MPlayer writer thread").start(); // if opening, wait long time = System.currentTimeMillis(); // Try to open the file during several secs while (UtilSystem.isRunning(proc) && !bStop && bOpening && !bEOF && (System.currentTimeMillis() - time) < MPLAYER_START_TIMEOUT) { try { Thread.sleep(50); } catch (InterruptedException e) { Log.error(e); } } } /* * (non-Javadoc) * * @see org.jajuk.players.IPlayerImpl#getCurrentPosition() */ @Override public float getCurrentPosition() { if (lDuration == 0) { return 0; } return ((float) lTime) / lDuration; } /* * (non-Javadoc) * * @see org.jajuk.players.IPlayerImpl#getElapsedTimeMillis() */ @Override public long getElapsedTimeMillis() { return lTime; } /* (non-Javadoc) * @see org.jajuk.services.players.IPlayerImpl#getActuallyPlayedTimeMillis() */ @Override public long getActuallyPlayedTimeMillis() { return actuallyPlayedTimeMillis; } /* * (non-Javadoc) * * @see org.jajuk.players.IPlayerImpl#seek(float) Ogg vorbis seek not yet supported */ @Override public void seek(float posValue) { // if fading, ignore if (bFading) { return; } // Make sure to reset pause. Indeed, mplayer has a bug that resume // playing after a volume change or a seek. We must make sure that // the jajuk state is coherent with the mplayer one if (Player.isPaused()) { try { ActionManager.getAction(JajukActions.PAUSE_RESUME_TRACK).perform(null); } catch (Exception e) { Log.error(e); } } // save current position String command = "seek " + (int) (100 * posValue) + " 1"; sendCommand(command); if (!Conf.getBoolean(CONF_BIT_PERFECT)) { setVolume(fVolume); // need this because a seek reset volume } } /** * Gets the state. * * @return player state, -1 if player is null. */ @Override public int getState() { if (bFading) { return FADING_STATUS; } else { return -1; } } /* * (non-Javadoc) * * @see org.jajuk.players.IPlayerImpl#getDurationSec() */ @Override public long getDurationSec() { return lDuration; } /* * (non-Javadoc) * * @see org.jajuk.players.AbstractMPlayerImpl#play(org.jajuk.base.WebRadio, float) */ @Override public void play(WebRadio radio, float volume) { // nothing to do here... } /* * (non-Javadoc) * * @see org.jajuk.base.IPlayerImpl#setVolume(float) */ @Override public void setVolume(float fVolume) { if (!bPaused) { super.setVolume(fVolume); } else { this.fVolume = fVolume; } } /* * (non-Javadoc) * * @see org.jajuk.services.players.AbstractMPlayerImpl#resume() */ @Override public void resume() throws Exception { lastPlayTimeUpdate = System.currentTimeMillis(); super.resume(); if (!Conf.getBoolean(CONF_BIT_PERFECT)) { setVolume(fVolume); } } /** * Force finishing (doesn't stop but only make a FIFO request to switch track) * <br> * We have to launch the next file from another thread to free the reader * thread. Otherwise, finish() calls launches() that call another finishes... */ private void callFinish() { // avoid stopping current track (perceptible during // player.open() for remote files) new Thread("Call to finish") { @Override public void run() { QueueModel.finished(); } }.start(); } }