diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/service/DownloadFile.java b/ultrasonic/src/main/java/org/moire/ultrasonic/service/DownloadFile.java
index d1ae5500..fac7939f 100644
--- a/ultrasonic/src/main/java/org/moire/ultrasonic/service/DownloadFile.java
+++ b/ultrasonic/src/main/java/org/moire/ultrasonic/service/DownloadFile.java
@@ -143,6 +143,14 @@ public class DownloadFile
 		return saveFile;
 	}
 
+	public File getCompleteOrPartialFile() {
+		if (isCompleteFileAvailable()) {
+			return getCompleteFile();
+		} else {
+			return getPartialFile();
+		}
+	}
+
 	public File getPartialFile()
 	{
 		return partialFile;
diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/service/LocalMediaPlayer.java b/ultrasonic/src/main/java/org/moire/ultrasonic/service/LocalMediaPlayer.java
deleted file mode 100644
index e12fed1a..00000000
--- a/ultrasonic/src/main/java/org/moire/ultrasonic/service/LocalMediaPlayer.java
+++ /dev/null
@@ -1,1037 +0,0 @@
-package org.moire.ultrasonic.service;
-
-import android.annotation.SuppressLint;
-import android.app.PendingIntent;
-import android.content.ComponentName;
-import android.content.Context;
-import android.content.Intent;
-import android.graphics.Bitmap;
-import android.media.AudioManager;
-import android.media.MediaMetadataRetriever;
-import android.media.MediaPlayer;
-import android.media.RemoteControlClient;
-import android.media.audiofx.AudioEffect;
-import android.os.Build;
-import android.os.Handler;
-import android.os.Looper;
-import android.os.PowerManager;
-import timber.log.Timber;
-import android.widget.SeekBar;
-
-import org.jetbrains.annotations.NotNull;
-import org.moire.ultrasonic.audiofx.EqualizerController;
-import org.moire.ultrasonic.audiofx.VisualizerController;
-import org.moire.ultrasonic.data.ActiveServerProvider;
-import org.moire.ultrasonic.domain.MusicDirectory;
-import org.moire.ultrasonic.domain.PlayerState;
-import org.moire.ultrasonic.fragment.PlayerFragment;
-import org.moire.ultrasonic.receiver.MediaButtonIntentReceiver;
-import org.moire.ultrasonic.util.CancellableTask;
-import org.moire.ultrasonic.util.Constants;
-import org.moire.ultrasonic.util.FileUtil;
-import org.moire.ultrasonic.util.StreamProxy;
-import org.moire.ultrasonic.util.Util;
-
-import java.io.File;
-import java.net.URLEncoder;
-import java.util.Locale;
-
-import static org.moire.ultrasonic.domain.PlayerState.COMPLETED;
-import static org.moire.ultrasonic.domain.PlayerState.DOWNLOADING;
-import static org.moire.ultrasonic.domain.PlayerState.IDLE;
-import static org.moire.ultrasonic.domain.PlayerState.PAUSED;
-import static org.moire.ultrasonic.domain.PlayerState.PREPARED;
-import static org.moire.ultrasonic.domain.PlayerState.PREPARING;
-import static org.moire.ultrasonic.domain.PlayerState.STARTED;
-
-/**
- * Represents a Media Player which uses the mobile's resources for playback
- */
-public class LocalMediaPlayer
-{
-    public Consumer<DownloadFile> onCurrentPlayingChanged;
-    public Consumer<DownloadFile> onSongCompleted;
-    public BiConsumer<PlayerState, DownloadFile> onPlayerStateChanged;
-    public Runnable onPrepared;
-    public Runnable onNextSongRequested;
-
-    public PlayerState playerState = IDLE;
-    public DownloadFile currentPlaying;
-    public DownloadFile nextPlaying;
-
-    private PlayerState nextPlayerState = IDLE;
-    private boolean nextSetup;
-    private CancellableTask nextPlayingTask;
-    private PowerManager.WakeLock wakeLock;
-
-    private MediaPlayer mediaPlayer;
-    private MediaPlayer nextMediaPlayer;
-    private Looper mediaPlayerLooper;
-    private Handler mediaPlayerHandler;
-    private int cachedPosition;
-    private StreamProxy proxy;
-
-    private AudioManager audioManager;
-    private RemoteControlClient remoteControlClient;
-
-    private CancellableTask bufferTask;
-    private PositionCache positionCache;
-    private int secondaryProgress = -1;
-
-    private final AudioFocusHandler audioFocusHandler;
-    private final Context context;
-
-    public LocalMediaPlayer(AudioFocusHandler audioFocusHandler, Context context)
-    {
-        this.audioFocusHandler = audioFocusHandler;
-        this.context = context;
-    }
-
-    public void onCreate()
-    {
-        if (mediaPlayer != null)
-        {
-            mediaPlayer.release();
-        }
-
-        mediaPlayer = new MediaPlayer();
-
-        new Thread(new Runnable()
-        {
-            @Override
-            public void run()
-            {
-                Thread.currentThread().setName("MediaPlayerThread");
-                Looper.prepare();
-                mediaPlayer.setWakeMode(context, PowerManager.PARTIAL_WAKE_LOCK);
-
-                mediaPlayer.setOnErrorListener(new MediaPlayer.OnErrorListener()
-                {
-                    @Override
-                    public boolean onError(MediaPlayer mediaPlayer, int what, int more)
-                    {
-                        handleError(new Exception(String.format(Locale.getDefault(), "MediaPlayer error: %d (%d)", what, more)));
-                        return false;
-                    }
-                });
-
-                try
-                {
-                    Intent i = new Intent(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION);
-                    i.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, mediaPlayer.getAudioSessionId());
-                    i.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.getPackageName());
-                    context.sendBroadcast(i);
-                }
-                catch (Throwable e)
-                {
-                    // Froyo or lower
-                }
-
-                mediaPlayerLooper = Looper.myLooper();
-                mediaPlayerHandler = new Handler(mediaPlayerLooper);
-                Looper.loop();
-            }
-        }).start();
-
-        // Create Equalizer and Visualizer on a new thread as this can potentially take some time
-        new Thread(new Runnable() {
-            @Override
-            public void run() {
-                EqualizerController.create(context, mediaPlayer);
-                VisualizerController.create(mediaPlayer);
-            }
-        }).start();
-
-        PowerManager pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE);
-        wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, this.getClass().getName());
-        wakeLock.setReferenceCounted(false);
-
-        audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
-        Util.registerMediaButtonEventReceiver(context, true);
-        setUpRemoteControlClient();
-
-        Timber.i("LocalMediaPlayer created");
-    }
-
-    public void onDestroy()
-    {
-        reset();
-
-        try
-        {
-            Intent i = new Intent(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION);
-            i.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, mediaPlayer.getAudioSessionId());
-            i.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.getPackageName());
-            context.sendBroadcast(i);
-
-            EqualizerController.release();
-            VisualizerController.release();
-
-            mediaPlayer.release();
-            if (nextMediaPlayer != null)
-            {
-                nextMediaPlayer.release();
-            }
-
-            mediaPlayerLooper.quit();
-
-            if (bufferTask != null)
-            {
-                bufferTask.cancel();
-            }
-
-            if (nextPlayingTask != null)
-            {
-                nextPlayingTask.cancel();
-            }
-
-            audioManager.unregisterRemoteControlClient(remoteControlClient);
-            clearRemoteControl();
-            Util.unregisterMediaButtonEventReceiver(context, true);
-            wakeLock.release();
-        }
-        catch (Throwable exception)
-        {
-            Timber.w(exception, "LocalMediaPlayer onDestroy exception: ");
-        }
-
-        Timber.i("LocalMediaPlayer destroyed");
-    }
-
-    public synchronized void setPlayerState(final PlayerState playerState)
-    {
-        Timber.i("%s -> %s (%s)", this.playerState.name(), playerState.name(), currentPlaying);
-
-        this.playerState = playerState;
-
-        if (playerState == PlayerState.STARTED)
-        {
-            audioFocusHandler.requestAudioFocus();
-        }
-
-        if (playerState == PlayerState.STARTED || playerState == PlayerState.PAUSED)
-        {
-            updateRemoteControl();
-        }
-
-        if (onPlayerStateChanged != null)
-        {
-            Handler mainHandler = new Handler(context.getMainLooper());
-            Runnable myRunnable = new Runnable() {
-                @Override
-                public void run() {
-                    onPlayerStateChanged.accept(playerState, currentPlaying);
-                }
-            };
-            mainHandler.post(myRunnable);
-        }
-
-        if (playerState == STARTED && positionCache == null)
-        {
-            positionCache = new PositionCache();
-            Thread thread = new Thread(positionCache);
-            thread.start();
-        }
-        else if (playerState != STARTED && positionCache != null)
-        {
-            positionCache.stop();
-            positionCache = null;
-        }
-    }
-
-    public synchronized void setCurrentPlaying(final DownloadFile currentPlaying)
-    {
-        Timber.v("setCurrentPlaying %s", currentPlaying);
-        this.currentPlaying = currentPlaying;
-        updateRemoteControl();
-
-        if (onCurrentPlayingChanged != null)
-        {
-            Handler mainHandler = new Handler(context.getMainLooper());
-            Runnable myRunnable = new Runnable() {
-                @Override
-                public void run() {
-                    onCurrentPlayingChanged.accept(currentPlaying);
-                }
-            };
-            mainHandler.post(myRunnable);
-        }
-    }
-
-    public synchronized void setNextPlaying(DownloadFile nextToPlay)
-    {
-        if (nextToPlay == null)
-        {
-            nextPlaying = null;
-            setNextPlayerState(IDLE);
-            return;
-        }
-
-        nextPlaying = nextToPlay;
-        nextPlayingTask = new CheckCompletionTask(nextPlaying);
-        nextPlayingTask.start();
-    }
-
-    public synchronized void clearNextPlaying()
-    {
-        nextSetup = false;
-        nextPlaying = null;
-        if (nextPlayingTask != null)
-        {
-            nextPlayingTask.cancel();
-            nextPlayingTask = null;
-        }
-    }
-
-    public synchronized void setNextPlayerState(PlayerState playerState)
-    {
-        Timber.i("Next: %s -> %s (%s)", nextPlayerState.name(), playerState.name(), nextPlaying);
-        nextPlayerState = playerState;
-    }
-
-    public synchronized void bufferAndPlay()
-    {
-        if (playerState != PREPARED)
-        {
-            reset();
-
-            bufferTask = new BufferTask(currentPlaying, 0);
-            bufferTask.start();
-        }
-        else
-        {
-            doPlay(currentPlaying, 0, true);
-        }
-    }
-
-    public synchronized void play(DownloadFile fileToPlay)
-    {
-        if (nextPlayingTask != null)
-        {
-            nextPlayingTask.cancel();
-            nextPlayingTask = null;
-        }
-
-        setCurrentPlaying(fileToPlay);
-        bufferAndPlay();
-    }
-
-    public synchronized void playNext()
-    {
-        MediaPlayer tmp = mediaPlayer;
-        mediaPlayer = nextMediaPlayer;
-        nextMediaPlayer = tmp;
-        setCurrentPlaying(nextPlaying);
-        setPlayerState(PlayerState.STARTED);
-        setupHandlers(currentPlaying, false);
-
-        if (onNextSongRequested != null) {
-            Handler mainHandler = new Handler(context.getMainLooper());
-            Runnable myRunnable = new Runnable() {
-                @Override
-                public void run() {
-                    onNextSongRequested.run();
-                }
-            };
-            mainHandler.post(myRunnable);
-        }
-
-        // Proxy should not be being used here since the next player was already setup to play
-        if (proxy != null)
-        {
-            proxy.stop();
-            proxy = null;
-        }
-    }
-
-    public synchronized void pause()
-    {
-        try
-        {
-            mediaPlayer.pause();
-        }
-        catch (Exception x)
-        {
-            handleError(x);
-        }
-    }
-
-    public synchronized void start()
-    {
-        try
-        {
-            mediaPlayer.start();
-        }
-        catch (Exception x)
-        {
-            handleError(x);
-        }
-    }
-
-    private void updateRemoteControl()
-    {
-        if (!Util.isLockScreenEnabled(context))
-        {
-            clearRemoteControl();
-            return;
-        }
-
-        if (remoteControlClient != null)
-        {
-            audioManager.unregisterRemoteControlClient(remoteControlClient);
-            audioManager.registerRemoteControlClient(remoteControlClient);
-        }
-        else
-        {
-            setUpRemoteControlClient();
-        }
-
-        Timber.i("In updateRemoteControl, playerState: %s [%d]", playerState, getPlayerPosition());
-
-        if (playerState == STARTED) {
-            if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2) {
-                remoteControlClient.setPlaybackState(RemoteControlClient.PLAYSTATE_PLAYING);
-            } else {
-                remoteControlClient.setPlaybackState(RemoteControlClient.PLAYSTATE_PLAYING, getPlayerPosition(), 1.0f);
-            }
-        } else {
-            if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2) {
-                remoteControlClient.setPlaybackState(RemoteControlClient.PLAYSTATE_PAUSED);
-            } else {
-                remoteControlClient.setPlaybackState(RemoteControlClient.PLAYSTATE_PAUSED, getPlayerPosition(), 1.0f);
-            }
-        }
-
-        if (currentPlaying != null)
-        {
-            MusicDirectory.Entry currentSong = currentPlaying.getSong();
-
-            Bitmap lockScreenBitmap = FileUtil.getAlbumArtBitmap(context, currentSong, Util.getMinDisplayMetric(context), true);
-
-            String artist = currentSong.getArtist();
-            String album = currentSong.getAlbum();
-            String title = currentSong.getTitle();
-            Integer currentSongDuration = currentSong.getDuration();
-            long duration = 0L;
-
-            if (currentSongDuration != null) duration = (long) currentSongDuration * 1000;
-
-            remoteControlClient.editMetadata(true)
-                    .putString(MediaMetadataRetriever.METADATA_KEY_ARTIST, artist)
-                    .putString(MediaMetadataRetriever.METADATA_KEY_ALBUMARTIST, artist)
-                    .putString(MediaMetadataRetriever.METADATA_KEY_ALBUM, album)
-                    .putString(MediaMetadataRetriever.METADATA_KEY_TITLE, title)
-                    .putLong(MediaMetadataRetriever.METADATA_KEY_DURATION, duration)
-                    .putBitmap(RemoteControlClient.MetadataEditor.BITMAP_KEY_ARTWORK, lockScreenBitmap)
-                    .apply();
-        }
-    }
-
-    public void clearRemoteControl()
-    {
-        if (remoteControlClient != null)
-        {
-            remoteControlClient.setPlaybackState(RemoteControlClient.PLAYSTATE_STOPPED);
-            audioManager.unregisterRemoteControlClient(remoteControlClient);
-            remoteControlClient = null;
-        }
-    }
-
-    private void setUpRemoteControlClient()
-    {
-        if (!Util.isLockScreenEnabled(context)) return;
-
-        ComponentName componentName = new ComponentName(context.getPackageName(), MediaButtonIntentReceiver.class.getName());
-
-        if (remoteControlClient == null)
-        {
-            final Intent mediaButtonIntent = new Intent(Intent.ACTION_MEDIA_BUTTON);
-            mediaButtonIntent.setComponent(componentName);
-            PendingIntent broadcast = PendingIntent.getBroadcast(context, 0, mediaButtonIntent, PendingIntent.FLAG_UPDATE_CURRENT);
-            remoteControlClient = new RemoteControlClient(broadcast);
-            audioManager.registerRemoteControlClient(remoteControlClient);
-
-            // Flags for the media transport control that this client supports.
-            int flags = RemoteControlClient.FLAG_KEY_MEDIA_PREVIOUS |
-                    RemoteControlClient.FLAG_KEY_MEDIA_NEXT |
-                    RemoteControlClient.FLAG_KEY_MEDIA_PLAY |
-                    RemoteControlClient.FLAG_KEY_MEDIA_PAUSE |
-                    RemoteControlClient.FLAG_KEY_MEDIA_PLAY_PAUSE |
-                    RemoteControlClient.FLAG_KEY_MEDIA_STOP;
-
-            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2)
-            {
-                flags |= RemoteControlClient.FLAG_KEY_MEDIA_POSITION_UPDATE;
-
-                remoteControlClient.setOnGetPlaybackPositionListener(new RemoteControlClient.OnGetPlaybackPositionListener()
-                {
-                    @Override
-                    public long onGetPlaybackPosition()
-                    {
-                        return mediaPlayer.getCurrentPosition();
-                    }
-                });
-
-                remoteControlClient.setPlaybackPositionUpdateListener(new RemoteControlClient.OnPlaybackPositionUpdateListener()
-                {
-                    @Override
-                    public void onPlaybackPositionUpdate(long newPositionMs)
-                    {
-                        seekTo((int) newPositionMs);
-                    }
-                });
-            }
-
-            remoteControlClient.setTransportControlFlags(flags);
-        }
-    }
-
-    public synchronized void seekTo(int position)
-    {
-        try
-        {
-            mediaPlayer.seekTo(position);
-            cachedPosition = position;
-
-            updateRemoteControl();
-        }
-        catch (Exception x)
-        {
-            handleError(x);
-        }
-    }
-
-    public synchronized int getPlayerPosition()
-    {
-        try
-        {
-            if (playerState == IDLE || playerState == DOWNLOADING || playerState == PREPARING)
-            {
-                return 0;
-            }
-
-            return cachedPosition;
-        }
-        catch (Exception x)
-        {
-            handleError(x);
-            return 0;
-        }
-    }
-
-    public synchronized int getPlayerDuration()
-    {
-        if (currentPlaying != null)
-        {
-            Integer duration = currentPlaying.getSong().getDuration();
-            if (duration != null)
-            {
-                return duration * 1000;
-            }
-        }
-        if (playerState != IDLE && playerState != DOWNLOADING && playerState != PlayerState.PREPARING)
-        {
-            try
-            {
-                return mediaPlayer.getDuration();
-            }
-            catch (Exception x)
-            {
-                handleError(x);
-            }
-        }
-        return 0;
-    }
-
-    public void setVolume(float volume)
-    {
-        if (mediaPlayer != null)
-        {
-            mediaPlayer.setVolume(volume, volume);
-        }
-    }
-
-    public synchronized void doPlay(final DownloadFile downloadFile, final int position, final boolean start)
-    {
-        try
-        {
-            downloadFile.setPlaying(false);
-            //downloadFile.setPlaying(true);
-            final File file = downloadFile.isCompleteFileAvailable() ? downloadFile.getCompleteFile() : downloadFile.getPartialFile();
-            boolean partial = file.equals(downloadFile.getPartialFile());
-            downloadFile.updateModificationDate();
-
-            mediaPlayer.setOnCompletionListener(null);
-            secondaryProgress = -1; // Ensure seeking in non StreamProxy playback works
-            mediaPlayer.reset();
-            setPlayerState(IDLE);
-            mediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC);
-            String dataSource = file.getPath();
-
-            if (partial)
-            {
-                if (proxy == null)
-                {
-                    proxy = new StreamProxy(new Supplier<DownloadFile>() {
-                        @Override
-                        public DownloadFile get() { return currentPlaying; }
-                    });
-                    proxy.start();
-                }
-
-                dataSource = String.format(Locale.getDefault(), "http://127.0.0.1:%d/%s",
-                        proxy.getPort(), URLEncoder.encode(dataSource, Constants.UTF_8));
-                Timber.i("Data Source: %s", dataSource);
-            }
-            else if (proxy != null)
-            {
-                proxy.stop();
-                proxy = null;
-            }
-
-            Timber.i("Preparing media player");
-            mediaPlayer.setDataSource(dataSource);
-            setPlayerState(PREPARING);
-
-            mediaPlayer.setOnBufferingUpdateListener(new MediaPlayer.OnBufferingUpdateListener()
-            {
-                @Override
-                public void onBufferingUpdate(MediaPlayer mp, int percent)
-                {
-                    SeekBar progressBar = PlayerFragment.getProgressBar();
-                    MusicDirectory.Entry song = downloadFile.getSong();
-
-                    if (percent == 100)
-                    {
-                        if (progressBar != null)
-                        {
-                            progressBar.setSecondaryProgress(100 * progressBar.getMax());
-                        }
-
-                        mp.setOnBufferingUpdateListener(null);
-                    }
-                    else if (progressBar != null && song.getTranscodedContentType() == null && Util.getMaxBitRate(context) == 0)
-                    {
-                        secondaryProgress = (int) (((double) percent / (double) 100) * progressBar.getMax());
-                        progressBar.setSecondaryProgress(secondaryProgress);
-                    }
-                }
-            });
-
-            mediaPlayer.setOnPreparedListener(new MediaPlayer.OnPreparedListener()
-            {
-                @Override
-                public void onPrepared(MediaPlayer mp)
-                {
-                    Timber.i("Media player prepared");
-
-                    setPlayerState(PREPARED);
-
-                    SeekBar progressBar = PlayerFragment.getProgressBar();
-
-                    if (progressBar != null && downloadFile.isWorkDone())
-                    {
-                        // Populate seek bar secondary progress if we have a complete file for consistency
-                        PlayerFragment.getProgressBar().setSecondaryProgress(100 * progressBar.getMax());
-                    }
-
-                    synchronized (LocalMediaPlayer.this)
-                    {
-                        if (position != 0)
-                        {
-                            Timber.i("Restarting player from position %d", position);
-                            seekTo(position);
-                        }
-                        cachedPosition = position;
-
-                        if (start)
-                        {
-                            mediaPlayer.start();
-                            setPlayerState(STARTED);
-                        }
-                        else
-                        {
-                            setPlayerState(PAUSED);
-                        }
-                    }
-
-                    if (onPrepared != null) {
-                        Handler mainHandler = new Handler(context.getMainLooper());
-                        Runnable myRunnable = new Runnable() {
-                            @Override
-                            public void run() {
-                                onPrepared.run();
-                            }
-                        };
-                        mainHandler.post(myRunnable);
-                    }
-                }
-            });
-
-            setupHandlers(downloadFile, partial);
-
-            mediaPlayer.prepareAsync();
-        }
-        catch (Exception x)
-        {
-            handleError(x);
-        }
-    }
-
-    private synchronized void setupNext(final DownloadFile downloadFile)
-    {
-        try
-        {
-            final File file = downloadFile.isCompleteFileAvailable() ? downloadFile.getCompleteFile() : downloadFile.getPartialFile();
-
-            if (nextMediaPlayer != null)
-            {
-                nextMediaPlayer.setOnCompletionListener(null);
-                nextMediaPlayer.release();
-                nextMediaPlayer = null;
-            }
-
-            nextMediaPlayer = new MediaPlayer();
-            nextMediaPlayer.setWakeMode(context, PowerManager.PARTIAL_WAKE_LOCK);
-
-            try
-            {
-                nextMediaPlayer.setAudioSessionId(mediaPlayer.getAudioSessionId());
-            }
-            catch (Throwable e)
-            {
-                nextMediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC);
-            }
-
-            nextMediaPlayer.setDataSource(file.getPath());
-            setNextPlayerState(PREPARING);
-
-            nextMediaPlayer.setOnPreparedListener(new MediaPlayer.OnPreparedListener()
-            {
-                @Override
-                @SuppressLint("NewApi")
-                public void onPrepared(MediaPlayer mp)
-                {
-                    try
-                    {
-                        setNextPlayerState(PREPARED);
-
-                        if (Util.getGaplessPlaybackPreference(context) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN && (playerState == PlayerState.STARTED || playerState == PlayerState.PAUSED))
-                        {
-                            mediaPlayer.setNextMediaPlayer(nextMediaPlayer);
-                            nextSetup = true;
-                        }
-                    }
-                    catch (Exception x)
-                    {
-                        handleErrorNext(x);
-                    }
-                }
-            });
-
-            nextMediaPlayer.setOnErrorListener(new MediaPlayer.OnErrorListener()
-            {
-                @Override
-                public boolean onError(MediaPlayer mediaPlayer, int what, int extra)
-                {
-                    Timber.w("Error on playing next (%d, %d): %s", what, extra, downloadFile);
-                    return true;
-                }
-            });
-
-            nextMediaPlayer.prepareAsync();
-        }
-        catch (Exception x)
-        {
-            handleErrorNext(x);
-        }
-    }
-
-    private void setupHandlers(final DownloadFile downloadFile, final boolean isPartial)
-    {
-        mediaPlayer.setOnErrorListener(new MediaPlayer.OnErrorListener()
-        {
-            @Override
-            public boolean onError(MediaPlayer mediaPlayer, int what, int extra)
-            {
-                Timber.w("Error on playing file (%d, %d): %s", what, extra, downloadFile);
-                int pos = cachedPosition;
-                reset();
-                downloadFile.setPlaying(false);
-                doPlay(downloadFile, pos, true);
-                downloadFile.setPlaying(true);
-                return true;
-            }
-        });
-
-        final int duration = downloadFile.getSong().getDuration() == null ? 0 : downloadFile.getSong().getDuration() * 1000;
-
-        mediaPlayer.setOnCompletionListener(new MediaPlayer.OnCompletionListener()
-        {
-            @Override
-            public void onCompletion(MediaPlayer mediaPlayer)
-            {
-                // Acquire a temporary wakelock, since when we return from
-                // this callback the MediaPlayer will release its wakelock
-                // and allow the device to go to sleep.
-                wakeLock.acquire(60000);
-
-                int pos = cachedPosition;
-                Timber.i("Ending position %d of %d", pos, duration);
-
-                if (!isPartial || (downloadFile.isWorkDone() && (Math.abs(duration - pos) < 1000)))
-                {
-                    setPlayerState(COMPLETED);
-
-                    if (Util.getGaplessPlaybackPreference(context) && nextPlaying != null && nextPlayerState == PlayerState.PREPARED)
-                    {
-                        if (nextSetup)
-                        {
-                            nextSetup = false;
-                        }
-                        playNext();
-                    }
-                    else
-                    {
-                        if (onSongCompleted != null)
-                        {
-                            Handler mainHandler = new Handler(context.getMainLooper());
-                            Runnable myRunnable = new Runnable() {
-                                @Override
-                                public void run() {
-                                    onSongCompleted.accept(currentPlaying);
-                                }
-                            };
-                            mainHandler.post(myRunnable);
-                        }
-                    }
-
-                    return;
-                }
-
-                synchronized (this)
-                {
-                    if (downloadFile.isWorkDone())
-                    {
-                        // Complete was called early even though file is fully buffered
-                        Timber.i("Requesting restart from %d of %d", pos, duration);
-                        reset();
-                        downloadFile.setPlaying(false);
-                        doPlay(downloadFile, pos, true);
-                        downloadFile.setPlaying(true);
-                    }
-                    else
-                    {
-                        Timber.i("Requesting restart from %d of %d", pos, duration);
-                        reset();
-                        bufferTask = new BufferTask(downloadFile, pos);
-                        bufferTask.start();
-                    }
-                }
-            }
-        });
-    }
-
-    public synchronized void reset()
-    {
-        if (bufferTask != null)
-        {
-            bufferTask.cancel();
-        }
-        try
-        {
-            setPlayerState(IDLE);
-            mediaPlayer.setOnErrorListener(null);
-            mediaPlayer.setOnCompletionListener(null);
-            mediaPlayer.reset();
-        }
-        catch (Exception x)
-        {
-            handleError(x);
-        }
-    }
-
-    private class BufferTask extends CancellableTask
-    {
-        private final DownloadFile downloadFile;
-        private final int position;
-        private final long expectedFileSize;
-        private final File partialFile;
-
-        public BufferTask(DownloadFile downloadFile, int position)
-        {
-            this.downloadFile = downloadFile;
-            this.position = position;
-            partialFile = downloadFile.getPartialFile();
-
-            long bufferLength = Util.getBufferLength(context);
-
-            if (bufferLength == 0)
-            {
-                // Set to seconds in a day, basically infinity
-                bufferLength = 86400L;
-            }
-
-            // Calculate roughly how many bytes BUFFER_LENGTH_SECONDS corresponds to.
-            int bitRate = downloadFile.getBitRate();
-            long byteCount = Math.max(100000, bitRate * 1024L / 8L * bufferLength);
-
-            // Find out how large the file should grow before resuming playback.
-            Timber.i("Buffering from position %d and bitrate %d", position, bitRate);
-            expectedFileSize = (position * bitRate / 8) + byteCount;
-        }
-
-        @Override
-        public void execute()
-        {
-            setPlayerState(DOWNLOADING);
-
-            while (!bufferComplete() && !ActiveServerProvider.Companion.isOffline(context))
-            {
-                Util.sleepQuietly(1000L);
-                if (isCancelled())
-                {
-                    return;
-                }
-            }
-            doPlay(downloadFile, position, true);
-        }
-
-        private boolean bufferComplete()
-        {
-            boolean completeFileAvailable = downloadFile.isWorkDone();
-            long size = partialFile.length();
-
-            Timber.i("Buffering %s (%d/%d, %s)", partialFile, size, expectedFileSize, completeFileAvailable);
-            return completeFileAvailable || size >= expectedFileSize;
-        }
-
-        @NotNull
-        @Override
-        public String toString()
-        {
-            return String.format("BufferTask (%s)", downloadFile);
-        }
-    }
-
-    private class CheckCompletionTask extends CancellableTask
-    {
-        private final DownloadFile downloadFile;
-        private final File partialFile;
-
-        public CheckCompletionTask(DownloadFile downloadFile)
-        {
-            super();
-            setNextPlayerState(PlayerState.IDLE);
-
-            this.downloadFile = downloadFile;
-
-            partialFile = downloadFile != null ? downloadFile.getPartialFile() : null;
-        }
-
-        @Override
-        public void execute()
-        {
-            Thread.currentThread().setName("CheckCompletionTask");
-
-            if (downloadFile == null)
-            {
-                return;
-            }
-
-            // Do an initial sleep so this prepare can't compete with main prepare
-            Util.sleepQuietly(5000L);
-
-            while (!bufferComplete())
-            {
-                Util.sleepQuietly(5000L);
-
-                if (isCancelled())
-                {
-                    return;
-                }
-            }
-
-            // Start the setup of the next media player
-            mediaPlayerHandler.post(new Runnable()
-            {
-                @Override
-                public void run()
-                {
-                    setupNext(downloadFile);
-                }
-            });
-        }
-
-        private boolean bufferComplete()
-        {
-            boolean completeFileAvailable = downloadFile.isWorkDone();
-            Timber.i("Buffering next %s (%d)", partialFile, partialFile.length());
-            return completeFileAvailable && (playerState == PlayerState.STARTED || playerState == PlayerState.PAUSED);
-        }
-
-        @NotNull
-        @Override
-        public String toString()
-        {
-            return String.format("CheckCompletionTask (%s)", downloadFile);
-        }
-
-    }
-
-    private class PositionCache implements Runnable
-    {
-        boolean isRunning = true;
-
-        public void stop()
-        {
-            isRunning = false;
-        }
-
-        @Override
-        public void run()
-        {
-            Thread.currentThread().setName("PositionCache");
-
-            // Stop checking position before the song reaches completion
-            while (isRunning)
-            {
-                try
-                {
-                    if (mediaPlayer != null && playerState == STARTED)
-                    {
-                        cachedPosition = mediaPlayer.getCurrentPosition();
-                    }
-
-                    Util.sleepQuietly(50L);
-                }
-                catch (Exception e)
-                {
-                    Timber.w(e, "Crashed getting current position");
-                    isRunning = false;
-                    positionCache = null;
-                }
-            }
-        }
-    }
-
-    private void handleError(Exception x)
-    {
-        Timber.w(x,"Media player error");
-
-        try
-        {
-            mediaPlayer.reset();
-        }
-        catch (Exception ex)
-        {
-            Timber.w(ex, "Exception encountered when resetting media player");
-        }
-    }
-
-    private void handleErrorNext(Exception x)
-    {
-        Timber.w(x, "Next Media player error");
-        nextMediaPlayer.reset();
-    }
-}
diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/service/MediaPlayerControllerImpl.java b/ultrasonic/src/main/java/org/moire/ultrasonic/service/MediaPlayerControllerImpl.java
index d5bffc02..516406d7 100644
--- a/ultrasonic/src/main/java/org/moire/ultrasonic/service/MediaPlayerControllerImpl.java
+++ b/ultrasonic/src/main/java/org/moire/ultrasonic/service/MediaPlayerControllerImpl.java
@@ -131,7 +131,7 @@ public class MediaPlayerControllerImpl implements MediaPlayerController
 						{
 							if (localMediaPlayer.currentPlaying.isCompleteFileAvailable())
 							{
-								localMediaPlayer.doPlay(localMediaPlayer.currentPlaying, currentPlayingPosition, autoPlay);
+								localMediaPlayer.play(localMediaPlayer.currentPlaying, currentPlayingPosition, autoPlay);
 							}
 						}
 					}
diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/service/MediaPlayerService.java b/ultrasonic/src/main/java/org/moire/ultrasonic/service/MediaPlayerService.java
index 76608c5d..c7ae0cf7 100644
--- a/ultrasonic/src/main/java/org/moire/ultrasonic/service/MediaPlayerService.java
+++ b/ultrasonic/src/main/java/org/moire/ultrasonic/service/MediaPlayerService.java
@@ -146,7 +146,7 @@ public class MediaPlayerService extends Service
         downloader.onCreate();
         shufflePlayBuffer.onCreate();
 
-        localMediaPlayer.onCreate();
+        localMediaPlayer.init();
         setupOnCurrentPlayingChangedHandler();
         setupOnPlayerStateChangedHandler();
         setupOnSongCompletedHandler();
@@ -198,7 +198,7 @@ public class MediaPlayerService extends Service
         instance = null;
 
         try {
-            localMediaPlayer.onDestroy();
+            localMediaPlayer.release();
             downloader.stop();
             shufflePlayBuffer.onDestroy();
         } catch (Throwable ignored) {
@@ -305,7 +305,7 @@ public class MediaPlayerService extends Service
 
         if (!gaplessPlayback)
         {
-            localMediaPlayer.setNextPlaying(null);
+            localMediaPlayer.clearNextPlaying(true);
             return;
         }
 
@@ -327,7 +327,7 @@ public class MediaPlayerService extends Service
             }
         }
 
-        localMediaPlayer.clearNextPlaying();
+        localMediaPlayer.clearNextPlaying(false);
 
         if (index < downloader.downloadList.size() && index != -1)
         {
@@ -335,7 +335,7 @@ public class MediaPlayerService extends Service
         }
         else
         {
-            localMediaPlayer.setNextPlaying(null);
+            localMediaPlayer.clearNextPlaying(true);
         }
     }
 
diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt
new file mode 100644
index 00000000..87df94f7
--- /dev/null
+++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt
@@ -0,0 +1,855 @@
+package org.moire.ultrasonic.service
+
+import android.app.PendingIntent
+import android.content.ComponentName
+import android.content.Context
+import android.content.Context.AUDIO_SERVICE
+import android.content.Context.POWER_SERVICE
+import android.content.Intent
+import android.media.AudioManager
+import android.media.MediaMetadataRetriever
+import android.media.MediaPlayer
+import android.media.MediaPlayer.OnCompletionListener
+import android.media.RemoteControlClient
+import android.media.audiofx.AudioEffect
+import android.os.Build
+import android.os.Handler
+import android.os.Looper
+import android.os.PowerManager
+import android.os.PowerManager.PARTIAL_WAKE_LOCK
+import android.os.PowerManager.WakeLock
+import java.io.File
+import java.net.URLEncoder
+import java.util.Locale
+import kotlin.math.abs
+import kotlin.math.max
+import org.moire.ultrasonic.audiofx.EqualizerController
+import org.moire.ultrasonic.audiofx.VisualizerController
+import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
+import org.moire.ultrasonic.domain.PlayerState
+import org.moire.ultrasonic.fragment.PlayerFragment
+import org.moire.ultrasonic.receiver.MediaButtonIntentReceiver
+import org.moire.ultrasonic.util.CancellableTask
+import org.moire.ultrasonic.util.Constants
+import org.moire.ultrasonic.util.FileUtil
+import org.moire.ultrasonic.util.StreamProxy
+import org.moire.ultrasonic.util.Util
+import timber.log.Timber
+
+/**
+ * Represents a Media Player which uses the mobile's resources for playback
+ */
+class LocalMediaPlayer(
+    private val audioFocusHandler: AudioFocusHandler,
+    private val context: Context
+) {
+
+    @JvmField
+    var onCurrentPlayingChanged: Consumer<DownloadFile?>? = null
+
+    @JvmField
+    var onSongCompleted: Consumer<DownloadFile?>? = null
+
+    @JvmField
+    var onPlayerStateChanged: BiConsumer<PlayerState, DownloadFile?>? = null
+
+    @JvmField
+    var onPrepared: Runnable? = null
+
+    @JvmField
+    var onNextSongRequested: Runnable? = null
+
+    @JvmField
+    var playerState = PlayerState.IDLE
+
+    @JvmField
+    var currentPlaying: DownloadFile? = null
+
+    @JvmField
+    var nextPlaying: DownloadFile? = null
+
+    private var nextPlayerState = PlayerState.IDLE
+    private var nextSetup = false
+    private var nextPlayingTask: CancellableTask? = null
+    private var mediaPlayer: MediaPlayer = MediaPlayer()
+    private var nextMediaPlayer: MediaPlayer? = null
+    private var mediaPlayerLooper: Looper? = null
+    private var mediaPlayerHandler: Handler? = null
+    private var cachedPosition = 0
+    private var proxy: StreamProxy? = null
+    private var audioManager: AudioManager = context.getSystemService(AUDIO_SERVICE) as AudioManager
+    private var remoteControlClient: RemoteControlClient? = null
+    private var bufferTask: CancellableTask? = null
+    private var positionCache: PositionCache? = null
+    private var secondaryProgress = -1
+    private val pm = context.getSystemService(POWER_SERVICE) as PowerManager
+    private val wakeLock: WakeLock = pm.newWakeLock(PARTIAL_WAKE_LOCK, this.javaClass.name)
+
+    fun init() {
+        Thread {
+            Thread.currentThread().name = "MediaPlayerThread"
+            Looper.prepare()
+            mediaPlayer.setWakeMode(context, PowerManager.PARTIAL_WAKE_LOCK)
+            mediaPlayer.setOnErrorListener { _, what, more ->
+                handleError(
+                    Exception(
+                        String.format(
+                            Locale.getDefault(),
+                            "MediaPlayer error: %d (%d)", what, more
+                        )
+                    )
+                )
+                false
+            }
+            try {
+                val i = Intent(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION)
+                i.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, mediaPlayer.audioSessionId)
+                i.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.packageName)
+                context.sendBroadcast(i)
+            } catch (e: Throwable) {
+                // Froyo or lower
+            }
+            mediaPlayerLooper = Looper.myLooper()
+            mediaPlayerHandler = Handler(mediaPlayerLooper!!)
+            Looper.loop()
+        }.start()
+
+        // Create Equalizer and Visualizer on a new thread as this can potentially take some time
+        Thread {
+            EqualizerController.create(context, mediaPlayer)
+            VisualizerController.create(mediaPlayer)
+        }.start()
+
+        wakeLock.setReferenceCounted(false)
+        Util.registerMediaButtonEventReceiver(context, true)
+        setUpRemoteControlClient()
+        Timber.i("LocalMediaPlayer created")
+    }
+
+    fun release() {
+        reset()
+        try {
+            val i = Intent(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION)
+            i.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, mediaPlayer.audioSessionId)
+            i.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.packageName)
+            context.sendBroadcast(i)
+            EqualizerController.release()
+            VisualizerController.release()
+            mediaPlayer.release()
+
+            mediaPlayer = MediaPlayer()
+
+            if (nextMediaPlayer != null) {
+                nextMediaPlayer!!.release()
+            }
+            mediaPlayerLooper!!.quit()
+            if (bufferTask != null) {
+                bufferTask!!.cancel()
+            }
+            if (nextPlayingTask != null) {
+                nextPlayingTask!!.cancel()
+            }
+            audioManager.unregisterRemoteControlClient(remoteControlClient)
+            clearRemoteControl()
+            Util.unregisterMediaButtonEventReceiver(context, true)
+            wakeLock.release()
+        } catch (exception: Throwable) {
+            Timber.w(exception, "LocalMediaPlayer onDestroy exception: ")
+        }
+        Timber.i("LocalMediaPlayer destroyed")
+    }
+
+    @Synchronized
+    fun setPlayerState(playerState: PlayerState) {
+        Timber.i("%s -> %s (%s)", this.playerState.name, playerState.name, currentPlaying)
+        this.playerState = playerState
+        if (playerState === PlayerState.STARTED) {
+            audioFocusHandler.requestAudioFocus()
+        }
+        if (playerState === PlayerState.STARTED || playerState === PlayerState.PAUSED) {
+            updateRemoteControl()
+        }
+        if (onPlayerStateChanged != null) {
+            val mainHandler = Handler(context.mainLooper)
+            val myRunnable = Runnable {
+                onPlayerStateChanged!!.accept(playerState, currentPlaying)
+            }
+            mainHandler.post(myRunnable)
+        }
+        if (playerState === PlayerState.STARTED && positionCache == null) {
+            positionCache = PositionCache()
+            val thread = Thread(positionCache)
+            thread.start()
+        } else if (playerState !== PlayerState.STARTED && positionCache != null) {
+            positionCache!!.stop()
+            positionCache = null
+        }
+    }
+
+    /*
+    * Set the current playing file. It's called with null to reset the player.
+    */
+    @Synchronized
+    fun setCurrentPlaying(currentPlaying: DownloadFile?) {
+        Timber.v("setCurrentPlaying %s", currentPlaying)
+        this.currentPlaying = currentPlaying
+        updateRemoteControl()
+
+        if (onCurrentPlayingChanged != null) {
+            val mainHandler = Handler(context.mainLooper)
+            val myRunnable = Runnable { onCurrentPlayingChanged!!.accept(currentPlaying) }
+            mainHandler.post(myRunnable)
+        }
+    }
+
+    /*
+    * Set the next playing file. nextToPlay cannot be null
+    */
+    @Synchronized
+    fun setNextPlaying(nextToPlay: DownloadFile) {
+        nextPlaying = nextToPlay
+        nextPlayingTask = CheckCompletionTask(nextPlaying)
+        nextPlayingTask?.start()
+    }
+
+    /*
+    * Clear the next playing file. setIdle controls whether the playerState is affected as well
+    */
+    @Synchronized
+    fun clearNextPlaying(setIdle: Boolean) {
+        nextSetup = false
+        nextPlaying = null
+        if (nextPlayingTask != null) {
+            nextPlayingTask!!.cancel()
+            nextPlayingTask = null
+        }
+
+        if (setIdle) {
+            setNextPlayerState(PlayerState.IDLE)
+        }
+    }
+
+    @Synchronized
+    fun setNextPlayerState(playerState: PlayerState) {
+        Timber.i("Next: %s -> %s (%s)", nextPlayerState.name, playerState.name, nextPlaying)
+        nextPlayerState = playerState
+    }
+
+    /*
+    * Public method to play a given file.
+    * Optionally specify a position to start at.
+    */
+    @Synchronized
+    @JvmOverloads
+    fun play(fileToPlay: DownloadFile?, position: Int = 0, autoStart: Boolean = true) {
+        if (nextPlayingTask != null) {
+            nextPlayingTask!!.cancel()
+            nextPlayingTask = null
+        }
+        setCurrentPlaying(fileToPlay)
+
+        if (fileToPlay != null) {
+            bufferAndPlay(fileToPlay, position, autoStart)
+        }
+    }
+
+    @Synchronized
+    fun playNext() {
+        if (nextMediaPlayer == null || nextPlaying == null) return
+
+        mediaPlayer = nextMediaPlayer!!
+
+        setCurrentPlaying(nextPlaying)
+        setPlayerState(PlayerState.STARTED)
+
+        attachHandlersToPlayer(mediaPlayer, nextPlaying!!, false)
+
+        postRunnable(onNextSongRequested)
+
+        // Proxy should not be being used here since the next player was already setup to play
+        proxy?.stop()
+        proxy = null
+    }
+
+    @Synchronized
+    fun pause() {
+        try {
+            mediaPlayer.pause()
+        } catch (x: Exception) {
+            handleError(x)
+        }
+    }
+
+    @Synchronized
+    fun start() {
+        try {
+            mediaPlayer.start()
+        } catch (x: Exception) {
+            handleError(x)
+        }
+    }
+
+    /*
+     * The remote control API is deprecated in API 21
+     */
+    private fun updateRemoteControl() {
+        if (!Util.isLockScreenEnabled(context)) {
+            clearRemoteControl()
+            return
+        }
+
+        if (remoteControlClient == null) {
+            remoteControlClient = createRemoteControlClient()
+        } else {
+            // This is probably needed only in API <=17
+            // "You must register your RemoteControlDisplay every time when the View which
+            // displays metadata is shown to the user. This is because 4.2.2 and lower
+            // versions support only one RemoteControlDisplay, and if system will
+            // decide to register it's own RCD, your RCD will be
+            // unregistered automatically.
+            // https://forum.xda-developers.com/t/guide-implement-your-own-lockscreen-like-music-controls.2401597/
+            audioManager.unregisterRemoteControlClient(remoteControlClient)
+            audioManager.registerRemoteControlClient(remoteControlClient)
+        }
+
+        Timber.i(
+            "In updateRemoteControl, playerState: %s [%d]",
+            playerState, playerPosition
+        )
+
+        if (playerState === PlayerState.STARTED) {
+            if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2) {
+                remoteControlClient!!.setPlaybackState(RemoteControlClient.PLAYSTATE_PLAYING)
+            } else {
+                remoteControlClient!!.setPlaybackState(
+                    RemoteControlClient.PLAYSTATE_PLAYING,
+                    playerPosition.toLong(), 1.0f
+                )
+            }
+        } else {
+            if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2) {
+                remoteControlClient!!.setPlaybackState(RemoteControlClient.PLAYSTATE_PAUSED)
+            } else {
+                remoteControlClient!!.setPlaybackState(
+                    RemoteControlClient.PLAYSTATE_PAUSED,
+                    playerPosition.toLong(), 1.0f
+                )
+            }
+        }
+
+        if (currentPlaying != null) {
+            val currentSong = currentPlaying!!.song
+            val lockScreenBitmap = FileUtil.getAlbumArtBitmap(
+                context, currentSong,
+                Util.getMinDisplayMetric(context), true
+            )
+            val artist = currentSong.artist
+            val album = currentSong.album
+            val title = currentSong.title
+            val currentSongDuration = currentSong.duration
+            var duration = 0L
+            if (currentSongDuration != null) duration = (currentSongDuration * 1000).toLong()
+            remoteControlClient!!.editMetadata(true)
+                .putString(MediaMetadataRetriever.METADATA_KEY_ARTIST, artist)
+                .putString(MediaMetadataRetriever.METADATA_KEY_ALBUMARTIST, artist)
+                .putString(MediaMetadataRetriever.METADATA_KEY_ALBUM, album)
+                .putString(MediaMetadataRetriever.METADATA_KEY_TITLE, title)
+                .putLong(MediaMetadataRetriever.METADATA_KEY_DURATION, duration)
+                .putBitmap(RemoteControlClient.MetadataEditor.BITMAP_KEY_ARTWORK, lockScreenBitmap)
+                .apply()
+        }
+    }
+
+    fun clearRemoteControl() {
+        if (remoteControlClient != null) {
+            remoteControlClient!!.setPlaybackState(RemoteControlClient.PLAYSTATE_STOPPED)
+            audioManager.unregisterRemoteControlClient(remoteControlClient)
+            remoteControlClient = null
+        }
+    }
+
+    private fun setUpRemoteControlClient() {
+        if (!Util.isLockScreenEnabled(context)) return
+
+        if (remoteControlClient == null) {
+            remoteControlClient = createRemoteControlClient()
+        }
+    }
+
+    private fun createRemoteControlClient(): RemoteControlClient {
+        val componentName = ComponentName(
+            context.packageName,
+            MediaButtonIntentReceiver::class.java.name
+        )
+
+        val mediaButtonIntent = Intent(Intent.ACTION_MEDIA_BUTTON)
+        mediaButtonIntent.component = componentName
+
+        val broadcast = PendingIntent.getBroadcast(
+            context, 0,
+            mediaButtonIntent, PendingIntent.FLAG_UPDATE_CURRENT
+        )
+
+        val remoteControlClient = RemoteControlClient(broadcast)
+        audioManager.registerRemoteControlClient(remoteControlClient)
+
+        // Flags for the media transport control that this client supports.
+        var flags = RemoteControlClient.FLAG_KEY_MEDIA_PREVIOUS or
+            RemoteControlClient.FLAG_KEY_MEDIA_NEXT or
+            RemoteControlClient.FLAG_KEY_MEDIA_PLAY or
+            RemoteControlClient.FLAG_KEY_MEDIA_PAUSE or
+            RemoteControlClient.FLAG_KEY_MEDIA_PLAY_PAUSE or
+            RemoteControlClient.FLAG_KEY_MEDIA_STOP
+
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) {
+            flags = flags or RemoteControlClient.FLAG_KEY_MEDIA_POSITION_UPDATE
+            remoteControlClient.setOnGetPlaybackPositionListener {
+                mediaPlayer.currentPosition.toLong()
+            }
+            remoteControlClient.setPlaybackPositionUpdateListener {
+                newPositionMs ->
+                seekTo(newPositionMs.toInt())
+            }
+        }
+
+        remoteControlClient.setTransportControlFlags(flags)
+
+        return remoteControlClient
+    }
+
+    @Synchronized
+    fun seekTo(position: Int) {
+        try {
+            mediaPlayer.seekTo(position)
+            cachedPosition = position
+            updateRemoteControl()
+        } catch (x: Exception) {
+            handleError(x)
+        }
+    }
+
+    @get:Synchronized
+    val playerPosition: Int
+        get() = try {
+            when (playerState) {
+                PlayerState.IDLE -> 0
+                PlayerState.DOWNLOADING -> 0
+                PlayerState.PREPARING -> 0
+                else -> cachedPosition
+            }
+        } catch (x: Exception) {
+            handleError(x)
+            0
+        }
+
+    @get:Synchronized
+    val playerDuration: Int
+        get() {
+            if (currentPlaying != null) {
+                val duration = currentPlaying!!.song.duration
+                if (duration != null) {
+                    return duration * 1000
+                }
+            }
+            if (playerState !== PlayerState.IDLE &&
+                playerState !== PlayerState.DOWNLOADING &&
+                playerState !== PlayerState.PREPARING
+            ) {
+                try {
+                    return mediaPlayer.duration
+                } catch (x: Exception) {
+                    handleError(x)
+                }
+            }
+            return 0
+        }
+
+    fun setVolume(volume: Float) {
+        mediaPlayer.setVolume(volume, volume)
+    }
+
+    @Synchronized
+    private fun bufferAndPlay(fileToPlay: DownloadFile, position: Int, autoStart: Boolean) {
+        if (playerState !== PlayerState.PREPARED) {
+            reset()
+            bufferTask = BufferTask(fileToPlay, position, autoStart)
+            bufferTask!!.start()
+        } else {
+            doPlay(fileToPlay, position, autoStart)
+        }
+    }
+
+    @Synchronized
+    private fun doPlay(downloadFile: DownloadFile, position: Int, start: Boolean) {
+
+        // In many cases we will be resetting the mediaPlayer a second time here.
+        // figure out if we can remove this call...
+        resetMediaPlayer()
+
+        try {
+            downloadFile.setPlaying(false)
+
+            val file = downloadFile.completeOrPartialFile
+            val partial = !downloadFile.isCompleteFileAvailable
+
+            downloadFile.updateModificationDate()
+            mediaPlayer.setOnCompletionListener(null)
+            secondaryProgress = -1 // Ensure seeking in non StreamProxy playback works
+
+            setPlayerState(PlayerState.IDLE)
+            mediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC)
+
+            var dataSource = file.path
+            if (partial) {
+                if (proxy == null) {
+                    proxy = StreamProxy(object : Supplier<DownloadFile?>() {
+                        override fun get(): DownloadFile {
+                            return currentPlaying!!
+                        }
+                    })
+                    proxy!!.start()
+                }
+                dataSource = String.format(
+                    Locale.getDefault(), "http://127.0.0.1:%d/%s",
+                    proxy!!.port, URLEncoder.encode(dataSource, Constants.UTF_8)
+                )
+                Timber.i("Data Source: %s", dataSource)
+            } else if (proxy != null) {
+                proxy?.stop()
+                proxy = null
+            }
+
+            Timber.i("Preparing media player")
+
+            mediaPlayer.setDataSource(dataSource)
+            setPlayerState(PlayerState.PREPARING)
+
+            mediaPlayer.setOnBufferingUpdateListener { mp, percent ->
+                val progressBar = PlayerFragment.getProgressBar()
+                val song = downloadFile.song
+
+                if (percent == 100) {
+                    mp.setOnBufferingUpdateListener(null)
+                }
+
+                secondaryProgress = (percent.toDouble() / 100.toDouble() * progressBar.max).toInt()
+
+                if (song.transcodedContentType == null && Util.getMaxBitRate(context) == 0) {
+                    progressBar?.secondaryProgress = secondaryProgress
+                }
+            }
+
+            mediaPlayer.setOnPreparedListener {
+                Timber.i("Media player prepared")
+                setPlayerState(PlayerState.PREPARED)
+                val progressBar = PlayerFragment.getProgressBar()
+                if (progressBar != null && downloadFile.isWorkDone) {
+                    // Populate seek bar secondary progress if we have a complete file for consistency
+                    PlayerFragment.getProgressBar().secondaryProgress = 100 * progressBar.max
+                }
+                synchronized(this@LocalMediaPlayer) {
+                    if (position != 0) {
+                        Timber.i("Restarting player from position %d", position)
+                        seekTo(position)
+                    }
+                    cachedPosition = position
+                    if (start) {
+                        mediaPlayer.start()
+                        setPlayerState(PlayerState.STARTED)
+                    } else {
+                        setPlayerState(PlayerState.PAUSED)
+                    }
+                }
+
+                postRunnable(onPrepared)
+            }
+            attachHandlersToPlayer(mediaPlayer, downloadFile, partial)
+            mediaPlayer.prepareAsync()
+        } catch (x: Exception) {
+            handleError(x)
+        }
+    }
+
+    @Synchronized
+    private fun setupNext(downloadFile: DownloadFile) {
+        try {
+            val file = downloadFile.completeOrPartialFile
+
+            if (nextMediaPlayer != null) {
+                nextMediaPlayer!!.setOnCompletionListener(null)
+                nextMediaPlayer!!.release()
+                nextMediaPlayer = null
+            }
+            nextMediaPlayer = MediaPlayer()
+            nextMediaPlayer!!.setWakeMode(context, PowerManager.PARTIAL_WAKE_LOCK)
+            try {
+                nextMediaPlayer!!.audioSessionId = mediaPlayer.audioSessionId
+            } catch (e: Throwable) {
+                nextMediaPlayer!!.setAudioStreamType(AudioManager.STREAM_MUSIC)
+            }
+            nextMediaPlayer!!.setDataSource(file.path)
+            setNextPlayerState(PlayerState.PREPARING)
+            nextMediaPlayer!!.setOnPreparedListener {
+                try {
+                    setNextPlayerState(PlayerState.PREPARED)
+                    if (Util.getGaplessPlaybackPreference(context) &&
+                        Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN &&
+                        (
+                            playerState === PlayerState.STARTED ||
+                                playerState === PlayerState.PAUSED
+                            )
+                    ) {
+                        mediaPlayer.setNextMediaPlayer(nextMediaPlayer)
+                        nextSetup = true
+                    }
+                } catch (x: Exception) {
+                    handleErrorNext(x)
+                }
+            }
+            nextMediaPlayer!!.setOnErrorListener { _, what, extra ->
+                Timber.w("Error on playing next (%d, %d): %s", what, extra, downloadFile)
+                true
+            }
+            nextMediaPlayer!!.prepareAsync()
+        } catch (x: Exception) {
+            handleErrorNext(x)
+        }
+    }
+
+    private fun attachHandlersToPlayer(
+        mediaPlayer: MediaPlayer,
+        downloadFile: DownloadFile,
+        isPartial: Boolean
+    ) {
+        mediaPlayer.setOnErrorListener { _, what, extra ->
+            Timber.w("Error on playing file (%d, %d): %s", what, extra, downloadFile)
+            val pos = cachedPosition
+            reset()
+            downloadFile.setPlaying(false)
+            doPlay(downloadFile, pos, true)
+            downloadFile.setPlaying(true)
+            true
+        }
+
+        var duration = 0
+        if (downloadFile.song.duration != null) {
+            duration = downloadFile.song.duration!! * 1000
+        }
+
+        mediaPlayer.setOnCompletionListener(object : OnCompletionListener {
+            override fun onCompletion(mediaPlayer: MediaPlayer) {
+                // Acquire a temporary wakelock, since when we return from
+                // this callback the MediaPlayer will release its wakelock
+                // and allow the device to go to sleep.
+                wakeLock.acquire(60000)
+                val pos = cachedPosition
+                Timber.i("Ending position %d of %d", pos, duration)
+                if (!isPartial || downloadFile.isWorkDone && abs(duration - pos) < 1000) {
+                    setPlayerState(PlayerState.COMPLETED)
+                    if (Util.getGaplessPlaybackPreference(context) &&
+                        nextPlaying != null &&
+                        nextPlayerState === PlayerState.PREPARED
+                    ) {
+                        if (nextSetup) {
+                            nextSetup = false
+                        }
+                        playNext()
+                    } else {
+                        if (onSongCompleted != null) {
+                            val mainHandler = Handler(context.mainLooper)
+                            val myRunnable = Runnable { onSongCompleted!!.accept(currentPlaying) }
+                            mainHandler.post(myRunnable)
+                        }
+                    }
+                    return
+                }
+                synchronized(this) {
+                    if (downloadFile.isWorkDone) {
+                        // Complete was called early even though file is fully buffered
+                        Timber.i("Requesting restart from %d of %d", pos, duration)
+                        reset()
+                        downloadFile.setPlaying(false)
+                        doPlay(downloadFile, pos, true)
+                        downloadFile.setPlaying(true)
+                    } else {
+                        Timber.i("Requesting restart from %d of %d", pos, duration)
+                        reset()
+                        bufferTask = BufferTask(downloadFile, pos)
+                        bufferTask!!.start()
+                    }
+                }
+            }
+        })
+    }
+
+    @Synchronized
+    fun reset() {
+        if (bufferTask != null) {
+            bufferTask!!.cancel()
+        }
+
+        resetMediaPlayer()
+
+        try {
+            setPlayerState(PlayerState.IDLE)
+            mediaPlayer.setOnErrorListener(null)
+            mediaPlayer.setOnCompletionListener(null)
+        } catch (x: Exception) {
+            handleError(x)
+        }
+    }
+
+    @Synchronized
+    fun resetMediaPlayer() {
+        try {
+            mediaPlayer.reset()
+        } catch (x: Exception) {
+            Timber.w(x, "MediaPlayer was released but LocalMediaPlayer was not destroyed")
+
+            // Recreate MediaPlayer
+            mediaPlayer = MediaPlayer()
+        }
+    }
+
+    private inner class BufferTask(
+        private val downloadFile: DownloadFile,
+        private val position: Int,
+        private val autoStart: Boolean = true
+    ) : CancellableTask() {
+        private val expectedFileSize: Long
+        private val partialFile: File = downloadFile.partialFile
+
+        override fun execute() {
+            setPlayerState(PlayerState.DOWNLOADING)
+            while (!bufferComplete() && !isOffline(context)) {
+                Util.sleepQuietly(1000L)
+                if (isCancelled) {
+                    return
+                }
+            }
+
+            doPlay(downloadFile, position, autoStart)
+        }
+
+        private fun bufferComplete(): Boolean {
+            val completeFileAvailable = downloadFile.isWorkDone
+            val size = partialFile.length()
+
+            Timber.i(
+                "Buffering %s (%d/%d, %s)",
+                partialFile, size, expectedFileSize, completeFileAvailable
+            )
+
+            return completeFileAvailable || size >= expectedFileSize
+        }
+
+        override fun toString(): String {
+            return String.format("BufferTask (%s)", downloadFile)
+        }
+
+        init {
+            var bufferLength = Util.getBufferLength(context).toLong()
+            if (bufferLength == 0L) {
+                // Set to seconds in a day, basically infinity
+                bufferLength = 86400L
+            }
+
+            // Calculate roughly how many bytes BUFFER_LENGTH_SECONDS corresponds to.
+            val bitRate = downloadFile.bitRate
+            val byteCount = max(100000, bitRate * 1024L / 8L * bufferLength)
+
+            // Find out how large the file should grow before resuming playback.
+            Timber.i("Buffering from position %d and bitrate %d", position, bitRate)
+            expectedFileSize = position * bitRate / 8 + byteCount
+        }
+    }
+
+    private inner class CheckCompletionTask(downloadFile: DownloadFile?) : CancellableTask() {
+        private val downloadFile: DownloadFile?
+        private val partialFile: File?
+        override fun execute() {
+            Thread.currentThread().name = "CheckCompletionTask"
+            if (downloadFile == null) {
+                return
+            }
+
+            // Do an initial sleep so this prepare can't compete with main prepare
+            Util.sleepQuietly(5000L)
+            while (!bufferComplete()) {
+                Util.sleepQuietly(5000L)
+                if (isCancelled) {
+                    return
+                }
+            }
+
+            // Start the setup of the next media player
+            mediaPlayerHandler!!.post { setupNext(downloadFile) }
+        }
+
+        private fun bufferComplete(): Boolean {
+            val completeFileAvailable = downloadFile!!.isWorkDone
+            val state = (playerState === PlayerState.STARTED || playerState === PlayerState.PAUSED)
+
+            Timber.i("Buffering next %s (%d)", partialFile, partialFile!!.length())
+
+            return completeFileAvailable && state
+        }
+
+        override fun toString(): String {
+            return String.format("CheckCompletionTask (%s)", downloadFile)
+        }
+
+        init {
+            setNextPlayerState(PlayerState.IDLE)
+            this.downloadFile = downloadFile
+            partialFile = downloadFile?.partialFile
+        }
+    }
+
+    private inner class PositionCache : Runnable {
+        var isRunning = true
+        fun stop() {
+            isRunning = false
+        }
+
+        override fun run() {
+            Thread.currentThread().name = "PositionCache"
+
+            // Stop checking position before the song reaches completion
+            while (isRunning) {
+                try {
+                    if (playerState === PlayerState.STARTED) {
+                        cachedPosition = mediaPlayer.currentPosition
+                    }
+                    Util.sleepQuietly(50L)
+                } catch (e: Exception) {
+                    Timber.w(e, "Crashed getting current position")
+                    isRunning = false
+                    positionCache = null
+                }
+            }
+        }
+    }
+
+    private fun handleError(x: Exception) {
+        Timber.w(x, "Media player error")
+        try {
+            mediaPlayer.reset()
+        } catch (ex: Exception) {
+            Timber.w(ex, "Exception encountered when resetting media player")
+        }
+    }
+
+    private fun handleErrorNext(x: Exception) {
+        Timber.w(x, "Next Media player error")
+        nextMediaPlayer!!.reset()
+    }
+
+    private fun postRunnable(runnable: Runnable?) {
+        if (runnable != null) {
+            val mainHandler = Handler(context.mainLooper)
+            val myRunnable = Runnable { runnable.run() }
+            mainHandler.post(myRunnable)
+        }
+    }
+}