/*
* Copyright (c) 2013, Sorokin Alexander (uas.sorokin@gmail.com)
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are
* met:
*
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* 3. The names of the authors may not be used to endorse or promote products
* derived from this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
* TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
* PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
* CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
* EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
* PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
* LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package com.uas.media.aimp.player;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.os.IBinder;
import com.uas.media.aimp.api.ApiException;
import com.uas.media.aimp.api.IPlugin;
import com.uas.media.aimp.api.models.CurrentSongInfo;
import com.uas.media.aimp.api.models.Playlist;
import com.uas.media.aimp.api.models.Song;
import com.uas.media.aimp.utils.Logger;
import java.io.IOException;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.*;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* User: uas.sorokin@gmail.com
*/
public class AimpPlayer {
private static final int SONG_POSITION_UPDATER_DELAY = 0;
private static final int SONG_POSITION_UPDATER_INTERVAL = 1000;
private static final int ERRORS_COUNT_TO_CANCEL_CONNECT = 10;
private static final int TIMEOUT_TO_RESET_ERRORS_COUNT = 1000*60;
public enum ConnectionStatus {
DISCONNECTED,
CONNECTING,
INIT,
CONNECTED,
DISCONNECTING
}
public enum PlayState {
PLAYING, STOPPED, PAUSED
}
private enum Commands {
SYNC,
PLAY, STOP, PAUSE, NEXT, PREVIOUS,
REPEAT, SHUFFLE, MUTE, VOLUME,
CHANGE_SONG, CHANGE_SONG_PLAY_POSITION,
REMOVE
}
private interface ApiTask {
void execute() throws Exception;
}
private List<ConnectionListener> mConnectionListeners;
private List<StateObserver> mStateObservers;
private Context mContext;
private IPlugin mPlugin;
private SyncParams mSyncParams;
private ConnectionStatus mConnStatus;
private ServiceConnection mServiceConnection;
private SyncService mSyncService;
private Thread tFirstConnectionThread;
private Thread tDisconnectThread;
private ExecutorService mExecutorService;
private int mErrorsCount;
private long mErrorsLastRaiseTime;
private volatile List<Playlist> mPlaylists;
private volatile int mCurrentPlaylistId;
private volatile int mCurrentSongPosition;
private volatile PlayState mPlayState;
private volatile int mSongPlayPosition;
private volatile int mVolume;
private volatile boolean mIsShuffle;
private volatile boolean mIsRepeatSong;
private volatile boolean mIsMute;
private volatile int mVolumeBeforeMute;
private Timer tSongPositionUpdater;
public AimpPlayer() {
init();
stateInit();
}
protected void init() {
mConnectionListeners = new CopyOnWriteArrayList<ConnectionListener>();
mStateObservers = new CopyOnWriteArrayList<StateObserver>();
mConnStatus = ConnectionStatus.DISCONNECTED;
mErrorsCount = 0;
mErrorsLastRaiseTime = -1;
}
// ======================================
public int getErrorsCountToCancelConnect() {
return ERRORS_COUNT_TO_CANCEL_CONNECT;
}
public long getTimeoutToResetErrorsCount() {
return TIMEOUT_TO_RESET_ERRORS_COUNT;
}
public synchronized IPlugin getPlugin() {
return mPlugin;
}
// ======================================
// ======= REGISTER LISTENERS & OBSERVERS
// ======================================
public void registerConnectionListener(ConnectionListener listener) {
if (listener == null) {
throw new IllegalArgumentException("Listener is null");
}
mConnectionListeners.add(listener);
}
public void unregisterConnectionListener(ConnectionListener listener) {
if (listener == null) {
throw new IllegalArgumentException("Listener is null");
}
mConnectionListeners.remove(listener);
}
public void registerStateObserver(StateObserver observer) {
if (observer == null) {
throw new IllegalArgumentException("Observer is null");
}
mStateObservers.add(observer);
}
public void unregisterStateObserver(StateObserver observer) {
if (observer == null) {
throw new IllegalArgumentException("Observer is null");
}
mStateObservers.remove(observer);
}
// ======================================
// ======= CONNECTION & DISCONNECTION WRAPPERS
// ======================================
public synchronized boolean isConnected() {
return mConnStatus == ConnectionStatus.CONNECTED;
}
public synchronized boolean isConnecting() {
return !isConnected() && !isDisconnected();
}
public synchronized boolean isDisconnected() {
return mConnStatus == ConnectionStatus.DISCONNECTED;
}
public synchronized void connect(Context context, IPlugin plugin, SyncParams syncParams) throws AimpException {
if (context == null)
throw new IllegalArgumentException("Context is null");
if (plugin == null)
throw new IllegalArgumentException("Plugin is null");
if (syncParams == null)
throw new IllegalArgumentException("Sync params is null");
if (plugin.getRemoteHost() == null || plugin.getRemotePort() < 1 || plugin.getRemotePort() > 65535)
throw new IllegalArgumentException("Host is not specified or port is not valid");
if (isConnecting() || isConnected()) {
throw new AimpException("Already connected or connecting");
}
mContext = context.getApplicationContext();
mPlugin = plugin;
mSyncParams = syncParams;
mConnStatus = ConnectionStatus.CONNECTING;
tFirstConnectionThread = new Thread(new Runnable() {
@Override
public void run() {
try {
doConnectAndInit();
} catch (Exception ex) {
if (!(ex instanceof InterruptedException)) {
notifyUnresolvedError(ex);
}
try {
disconnect();
} catch (AimpException e) {
// already disconnecting. We skip this error
}
}
}
});
tFirstConnectionThread.setName("ConnectAndInitThread");
tFirstConnectionThread.start();
}
public synchronized void disconnect() throws AimpException {
if (tDisconnectThread != null) {
throw new AimpException("Disconnecting or not connected");
}
tDisconnectThread = new Thread(new Runnable() {
@Override
public void run() {
try {
tFirstConnectionThread.interrupt();
tFirstConnectionThread.join();
destroyAndCleanConnection();
} catch (InterruptedException e) {
// this thread will not be interrupted, so this message cannot be raised
}
}
});
tDisconnectThread.setName("DisconnectThread");
tDisconnectThread.start();
}
// ======================================
// ======= CONNECTION IMPLEMENTATION
// ======================================
/**
* Do connect and init the base state
* @throws ApiException If there was an error during the init
* @throws IOException If there was an error during the init
* @throws InterruptedException If connection operation was cancelled
*/
protected void doConnectAndInit() throws ApiException, IOException, InterruptedException {
// we update connection status earlier in connect()
notifyConnectionStatusChanged();
// trying to establish connection with remote host
try {
tryToResolveHost();
} catch (UnknownHostException ex) {
notifyHostNotFound();
throw new InterruptedException();
}
// ping to check is AIMP installed
if (!tryToPing()) {
notifyAimpNotFound();
throw new InterruptedException();
}
if (Thread.currentThread().isInterrupted()) {
throw new InterruptedException();
}
// now is init time
mConnStatus = ConnectionStatus.INIT;
notifyConnectionStatusChanged();
// AIMP is pinging, so let's init default values
initWithDefaults();
// AIMP is initialized, now create the sync service
tryToCreateAndConnectToService();
if (Thread.currentThread().isInterrupted()) {
throw new InterruptedException();
}
// we connected. notify
mConnStatus = ConnectionStatus.CONNECTED;
notifyConnectionStatusChanged();
// init executor
mExecutorService = Executors.newSingleThreadExecutor();
// all is OK, let's launch sync service
mSyncService.submitSync(this, mSyncParams, new SyncService.ErrorsCallback() {
@Override
public void onException(Exception ex) {
onExecutorException(Commands.SYNC, ex);
}
});
// launch the song position updater
tSongPositionUpdater = new Timer();
tSongPositionUpdater.scheduleAtFixedRate(
new SongPlayPositionUpdater(),
SONG_POSITION_UPDATER_DELAY,
SONG_POSITION_UPDATER_INTERVAL
);
}
protected void tryToResolveHost() throws UnknownHostException {
InetAddress.getByName(mPlugin.getRemoteHost());
}
protected boolean tryToPing() throws InterruptedException {
return mPlugin.ping();
}
protected void tryToCreateAndConnectToService() throws InterruptedException {
CountDownLatch cdl = new CountDownLatch(1);
mServiceConnection = new ServiceConnectionImpl(cdl);
mContext.bindService(
new Intent(mContext, SyncService.class),
mServiceConnection,
Context.BIND_AUTO_CREATE
);
cdl.await();
}
protected void initWithDefaults() throws ApiException, IOException, InterruptedException {
AimpPlayerPackageLoaders loaders = new AimpPlayerPackageLoaders(this);
loaders.loadPlaylists(AimpPlayerPackageLoaders.CHECK_HASH_NO);
loaders.loadCurrentSong();
loaders.loadCommons();
loaders.loadVolume();
loaders.loadSongPlayPosition();
}
// ======================================
// ======= DISCONNECTION IMPLEMENTATION
// ======================================
protected void destroyAndCleanConnection() {
mConnStatus = ConnectionStatus.DISCONNECTING;
notifyConnectionStatusChanged();
// if there's an active update thread - cancel()
if (mExecutorService != null) {
mExecutorService.shutdownNow();
mExecutorService = null;
}
// if sync service is active - cancel()
if (mSyncService != null) {
mContext.unbindService(mServiceConnection);
mContext.stopService(new Intent(mContext, SyncService.class));
mSyncService = null;
mServiceConnection = null;
}
mContext = null;
mPlugin = null;
mSyncParams = null;
mErrorsCount = 0;
mErrorsLastRaiseTime = -1;
stateClear();
synchronized (this) {
tFirstConnectionThread = null;
tDisconnectThread = null;
}
mConnStatus = ConnectionStatus.DISCONNECTED;
notifyConnectionStatusChanged();
}
// ======================================
// ======= NOTIFYERS
// ======================================
protected void notifyConnectionStatusChanged() {
for (ConnectionListener listener: mConnectionListeners) {
listener.notifyConnectionStatusChanged(mPlugin, mConnStatus);
}
}
protected void notifyHostNotFound() {
for (ConnectionListener listener: mConnectionListeners) {
listener.notifyHostNotFound(mPlugin);
}
}
protected void notifyAimpNotFound() {
for (ConnectionListener listener: mConnectionListeners) {
listener.notifyAimpNotFound(mPlugin);
}
}
protected void notifyUnresolvedError(Exception ex) {
for (ConnectionListener listener: mConnectionListeners) {
listener.notifyUnresolvedError(mPlugin, ex);
}
}
// ============================================================
// ============= STATE INIT
// ============================================================
protected void stateInit() {
mPlaylists = new ArrayList<Playlist>();
stateClear();
}
protected void stateClear() {
mPlaylists.clear();
mCurrentPlaylistId = -1;
mCurrentSongPosition = -1;
mPlayState = PlayState.STOPPED;
mSongPlayPosition = 0;
mVolume = 0;
mIsShuffle = false;
mIsRepeatSong = false;
mIsMute = false;
mVolumeBeforeMute = 0;
if (tSongPositionUpdater != null) {
tSongPositionUpdater.cancel();
tSongPositionUpdater.purge();
tSongPositionUpdater = null;
}
}
// ============================================================
// ============= GETTERS
// ============================================================
public synchronized boolean isPlaying() {
return mPlayState == PlayState.PLAYING;
}
public synchronized boolean isPaused() {
return mPlayState == PlayState.PAUSED;
}
public synchronized boolean isStopped() {
return mPlayState == PlayState.STOPPED;
}
public synchronized PlayState getPlayState() {
return mPlayState;
}
public synchronized int getVolume() {
return mVolume;
}
public synchronized boolean isShuffle() {
return mIsShuffle;
}
public synchronized boolean isRepeatSong() {
return mIsRepeatSong;
}
public synchronized boolean isMute() {
return mIsMute;
}
public synchronized boolean hasPlaylists() {
return mPlaylists.size() != 0;
}
public synchronized List<Playlist> getPlaylists() {
return Collections.unmodifiableList(mPlaylists);
}
public synchronized Playlist getPlaylistById(int id) {
for (Playlist pl: mPlaylists) {
if (pl.getId() == id) {
return pl;
}
}
return null;
}
public synchronized int getPlaylistPosition(int id) {
for (int i = 0; i < mPlaylists.size(); i++) {
if (mPlaylists.get(i).getId() == id) {
return i;
}
}
return -1;
}
public synchronized Playlist getCurrentPlaylist() {
return getPlaylistById(mCurrentPlaylistId);
}
public synchronized Song getCurrentSong() {
Playlist currentPlaylist = getCurrentPlaylist();
if (mCurrentSongPosition == -1 || currentPlaylist == null) {
return null;
} else {
return currentPlaylist.getSong(mCurrentSongPosition);
}
}
public synchronized int getSongPlayPosition() {
switch(mPlayState) {
case PLAYING:
case PAUSED:
return mSongPlayPosition;
case STOPPED:
default:
return 0;
}
}
// =============================================
// ============ SETTERS
// =============================================
protected synchronized void setStatePlaylists(List<Playlist> pls, int currentPlaylistId) {
int hashcodesOld = 0;
int hashcodesNew = 0;
for (Playlist pl: mPlaylists) {
hashcodesOld ^= pl.hashCode();
}
for (Playlist pl: pls) {
hashcodesNew ^= pl.hashCode();
}
if (hashcodesOld != hashcodesNew) {
mPlaylists.clear();
mPlaylists.addAll(pls);
mCurrentPlaylistId = currentPlaylistId;
for (StateObserver so: mStateObservers) {
so.notifyPlaylistsInfoUpdated(getPlaylists(), getCurrentPlaylist());
}
}
}
protected synchronized void setStateCurrentSong(int playlistId, int songPosition, int playPosition) {
boolean isSongChanged = (mCurrentPlaylistId != playlistId) || (mCurrentSongPosition != songPosition);
mCurrentPlaylistId = playlistId;
mCurrentSongPosition = songPosition;
if (songPosition < 0) {
mSongPlayPosition = 0;
for (StateObserver so: mStateObservers) {
so.notifySongChanged(getCurrentPlaylist(), null, 0, 0.0d);
}
} else {
mSongPlayPosition = playPosition;
if (isSongChanged) {
for (StateObserver so: mStateObservers) {
so.notifySongChanged(
getCurrentPlaylist(), getCurrentSong(),
mSongPlayPosition, (double) mSongPlayPosition / getCurrentSong().getDuration()
);
}
}
}
}
protected synchronized void setStatePlayState(PlayState playState) {
switch (playState) {
case PLAYING:
setStateIsPlaying();
break;
case STOPPED:
setStateIsStopped();
break;
case PAUSED:
setStateIsPaused();
break;
}
}
protected synchronized void setStateIsPlaying() {
if (mPlayState == PlayState.PLAYING) {
return;
}
mPlayState = PlayState.PLAYING;
for (StateObserver so: mStateObservers) {
so.notifyPlay(getCurrentSong());
}
}
protected synchronized void setStateIsStopped() {
if (mPlayState == PlayState.STOPPED) {
return;
}
mPlayState = PlayState.STOPPED;
for (StateObserver so: mStateObservers) {
so.notifyStop(getCurrentSong());
}
}
protected synchronized void setStateIsPaused() {
if (mPlayState == PlayState.PAUSED) {
return;
}
mPlayState = PlayState.PAUSED;
for (StateObserver so: mStateObservers) {
so.notifyPause(getCurrentSong());
}
}
protected synchronized void setStateSongPlayPosition(int seconds) {
boolean isPositionChanged = seconds != mSongPlayPosition;
mSongPlayPosition = seconds;
if (isPositionChanged) {
for (StateObserver so: mStateObservers) {
so.notifySongPlayPositionChanged(
getCurrentPlaylist(), getCurrentSong(),
mSongPlayPosition, (double) mSongPlayPosition / getCurrentSong().getDuration()
);
}
}
}
protected synchronized void setStateVolumeValue(int volume) {
boolean isVolumeChanged = volume == mVolume;
mVolume = volume;
if (mVolume > 0 && mIsMute) {
setStateIsMute(false);
}
if (isVolumeChanged) {
for (StateObserver so: mStateObservers) {
so.notifyVolumeChanged(mVolume);
}
}
}
protected synchronized void setStateIsShuffle(boolean state) {
if (mIsShuffle == state) {
return;
}
mIsShuffle = state;
for (StateObserver so: mStateObservers) {
so.notifyShuffleStateChanged(mIsShuffle);
}
}
protected synchronized void setStateIsRepeatSong(boolean state) {
if (mIsRepeatSong == state) {
return;
}
mIsRepeatSong = state;
for (StateObserver so: mStateObservers) {
so.notifyRepeatSongStateChanged(mIsRepeatSong);
}
}
protected synchronized void setStateIsMute(boolean state) {
if (mIsMute == state) {
return;
}
mIsMute = state;
for (StateObserver so: mStateObservers) {
so.notifyMuteStateChanged(mIsMute);
}
}
protected synchronized void removeSongFromPlaylist(int playlistId, int songPosition) {
Playlist pl = getPlaylistById(playlistId);
pl.removeSong(songPosition);
for (StateObserver so: mStateObservers) {
so.notifyPlaylistUpdated(pl);
}
}
// ============================================================
// ============= PUBLIC SETTERS
// ============================================================
public void play() {
submit(Commands.PLAY, new ApiTask() {
@Override
public void execute() throws Exception {
setStateIsPlaying();
getPlugin().play();
}
});
}
public void stop() {
submit(Commands.STOP, new ApiTask() {
@Override
public void execute() throws Exception {
setStateIsStopped();
getPlugin().stop();
}
});
}
public void pause() {
submit(Commands.PAUSE, new ApiTask() {
@Override
public void execute() throws Exception {
setStateIsPaused();
getPlugin().pause();
}
});
}
public void next() {
submit(Commands.NEXT, new ApiTask() {
@Override
public void execute() throws Exception {
getPlugin().next();
CurrentSongInfo csi = getPlugin().getCurrentSongInfo();
setStateCurrentSong(csi.getPlaylistId(), csi.getSongPosition(), 0);
}
});
}
public void previous() {
submit(Commands.PREVIOUS, new ApiTask() {
@Override
public void execute() throws Exception {
getPlugin().previous();
CurrentSongInfo csi = getPlugin().getCurrentSongInfo();
setStateCurrentSong(csi.getPlaylistId(), csi.getSongPosition(), 0);
}
});
}
public void setRepeatSong(final boolean state) {
submit(Commands.REPEAT, new ApiTask() {
@Override
public void execute() throws Exception {
setStateIsRepeatSong(state);
getPlugin().setRepeatSong(state);
}
});
}
public void setShuffle(final boolean state) {
submit(Commands.SHUFFLE, new ApiTask() {
@Override
public void execute() throws Exception {
setStateIsShuffle(state);
getPlugin().setShuffle(state);
}
});
}
public void setMute(final boolean state) {
submit(Commands.MUTE, new ApiTask() {
@Override
public void execute() throws Exception {
int volume_new = state ? 0 : mVolumeBeforeMute;
if (state) {
mVolumeBeforeMute = getVolume();
}
synchronized (AimpPlayer.this) {
setStateIsMute(state);
setStateVolumeValue(volume_new);
}
getPlugin().setMute(state);
}
});
}
public void setVolume(final int volume) {
submit(Commands.VOLUME, new ApiTask() {
@Override
public void execute() throws Exception {
setStateVolumeValue(volume);
getPlugin().setVolume(volume);
}
});
}
public void changeSong(final int playlistId, final int songPosition) {
submit(Commands.CHANGE_SONG, new ApiTask() {
@Override
public void execute() throws Exception {
Playlist pl = getPlaylistById(playlistId);
if (pl == null) {
throw new AimpException("Specified playlist is not found");
}
if (songPosition < 0 || songPosition >= pl.getSongs().size()) {
throw new AimpException("Specified song is not found");
}
synchronized (AimpPlayer.this) {
setStateCurrentSong(playlistId, songPosition, 0);
setStateIsPlaying();
}
getPlugin().play(playlistId, songPosition);
}
});
}
public void changeSongPlayPosition(final int position) {
submit(Commands.CHANGE_SONG_PLAY_POSITION, new ApiTask() {
@Override
public void execute() throws Exception {
if (isStopped()) {
return;
}
setStateSongPlayPosition(position);
getPlugin().setSongPlayPosition(position);
}
});
}
public void removeSong(final int playlistId, final int songPosition) {
submit(Commands.REMOVE, new ApiTask() {
@Override
public void execute() throws Exception {
getPlugin().removeSong(playlistId, songPosition);
removeSongFromPlaylist(playlistId, songPosition);
}
});
}
// =============================================
// ============ Executor
// =============================================
protected void submit(final Commands command, final ApiTask apiTask) {
Runnable r = new Runnable() {
@Override
public void run() {
try {
apiTask.execute();
} catch (Exception ex) {
onExecutorException(command, ex);
}
}
};
mExecutorService.submit(r);
}
protected void onExecutorException(Commands command, Exception ex) {
Logger.e(ex.getMessage(), ex);
if (ex instanceof InterruptedException) {
return;
}
long errorLastRaiseTimeDiffToNow = System.currentTimeMillis() - mErrorsLastRaiseTime;
if (errorLastRaiseTimeDiffToNow < getTimeoutToResetErrorsCount()) {
mErrorsCount++;
if (mErrorsCount >= getErrorsCountToCancelConnect()) {
try {
disconnect();
} catch (AimpException e) {
Logger.e(e.getMessage(), e);
}
} else {
mErrorsLastRaiseTime = System.currentTimeMillis();
}
} else {
mErrorsLastRaiseTime = System.currentTimeMillis();
mErrorsCount = 1;
}
}
class SongPlayPositionUpdater extends TimerTask {
@Override
public void run() {
synchronized (AimpPlayer.this) {
Song current = getCurrentSong();
if (current != null && mPlayState == PlayState.PLAYING && mSongPlayPosition < current.getDuration()) {
setStateSongPlayPosition(getSongPlayPosition() + 1);
}
}
}
}
class ServiceConnectionImpl implements ServiceConnection {
private CountDownLatch mLatch;
ServiceConnectionImpl(CountDownLatch latch) {
mLatch = latch;
}
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
mSyncService = ((SyncService.LocalBinder)service).getService();
mLatch.countDown();
}
@Override
public void onServiceDisconnected(ComponentName name) {
}
}
public static SyncParams defaultSyncParams() {
return new SyncParams() {
@Override
public long getPlaylistsUpdatePeriod() {
return 1000*60*5;
}
@Override
public long getPlaystateUpdatePeriod() {
return 1000*5;
}
@Override
public long getOthersUpdatePeriod() {
return 1000*10;
}
};
}
}