uk.org.ngo.squeezer.service.SqueezeService.java Source code

Java tutorial

Introduction

Here is the source code for uk.org.ngo.squeezer.service.SqueezeService.java

Source

/*
 * Copyright (c) 2009 Google Inc.  All Rights Reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package uk.org.ngo.squeezer.service;

import android.annotation.TargetApi;
import android.app.DownloadManager;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.media.MediaMetadata;
import android.media.session.MediaSession;
import android.net.Uri;
import android.net.wifi.WifiManager;
import android.os.Binder;
import android.os.Build;
import android.os.Environment;
import android.os.IBinder;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.app.NotificationCompat;
import android.support.v4.app.NotificationManagerCompat;
import android.util.Base64;
import android.util.Log;
import android.widget.RemoteViews;

import com.crashlytics.android.Crashlytics;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Pattern;

import uk.org.ngo.squeezer.NowPlayingActivity;
import uk.org.ngo.squeezer.Preferences;
import uk.org.ngo.squeezer.R;
import uk.org.ngo.squeezer.RandomplayActivity;
import uk.org.ngo.squeezer.Squeezer;
import uk.org.ngo.squeezer.Util;
import uk.org.ngo.squeezer.framework.BaseActivity;
import uk.org.ngo.squeezer.framework.FilterItem;
import uk.org.ngo.squeezer.framework.PlaylistItem;
import uk.org.ngo.squeezer.itemlist.IServiceItemListCallback;
import uk.org.ngo.squeezer.itemlist.PluginItemListActivity;
import uk.org.ngo.squeezer.itemlist.dialog.AlbumViewDialog;
import uk.org.ngo.squeezer.itemlist.dialog.SongViewDialog;
import uk.org.ngo.squeezer.model.Alarm;
import uk.org.ngo.squeezer.model.AlarmPlaylist;
import uk.org.ngo.squeezer.model.Album;
import uk.org.ngo.squeezer.model.Artist;
import uk.org.ngo.squeezer.model.Genre;
import uk.org.ngo.squeezer.model.MusicFolderItem;
import uk.org.ngo.squeezer.model.Player;
import uk.org.ngo.squeezer.model.PlayerState;
import uk.org.ngo.squeezer.model.Playlist;
import uk.org.ngo.squeezer.model.Plugin;
import uk.org.ngo.squeezer.model.PluginItem;
import uk.org.ngo.squeezer.model.Song;
import uk.org.ngo.squeezer.model.Year;
import uk.org.ngo.squeezer.service.event.ConnectionChanged;
import uk.org.ngo.squeezer.service.event.HandshakeComplete;
import uk.org.ngo.squeezer.service.event.MusicChanged;
import uk.org.ngo.squeezer.service.event.PlayStatusChanged;
import uk.org.ngo.squeezer.service.event.PlayerStateChanged;
import uk.org.ngo.squeezer.service.event.PlayersChanged;
import uk.org.ngo.squeezer.service.event.SongTimeChanged;
import uk.org.ngo.squeezer.util.ImageFetcher;
import uk.org.ngo.squeezer.util.ImageWorker;
import uk.org.ngo.squeezer.util.Scrobble;

public class SqueezeService extends Service implements ServiceCallbackList.ServicePublisher {

    private static final String TAG = "SqueezeService";

    private static final int PLAYBACKSERVICE_STATUS = 1;

    /** {@link java.util.regex.Pattern} that splits strings on spaces. */
    private static final Pattern mSpaceSplitPattern = Pattern.compile(" ");

    private static final String ALBUMTAGS = "alyj";

    /**
     * Information that will be requested about songs.
     * <p>
     * a: artist name<br/>
     * C: compilation (1 if true, missing otherwise)<br/>
     * d: duration, in seconds<br/>
     * e: album ID<br/>
     * j: coverart (1 if available, missing otherwise)<br/>
     * J: artwork_track_id (if available, missing otherwise)<br/>
     * K: URL to remote artwork<br/>
     * l: album name<br/>
     * s: artist id<br/>
     * t: tracknum, if known<br/>
     * x: 1, if this is a remote track<br/>
     * y: song year<br/>
     * u: Song file url
     */
    // This should probably be a field in Song.
    public static final String SONGTAGS = "aCdejJKlstxyu";

    /** Service-specific eventbus. All events generated by the service will be sent here. */
    private final EventBus mEventBus = new EventBus();

    /** Executor for off-main-thread work. */
    @NonNull
    private final ScheduledThreadPoolExecutor mExecutor = new ScheduledThreadPoolExecutor(1);

    /** True if the handshake with the server has completed, otherwise false. */
    private volatile boolean mHandshakeComplete = false;

    /** Media session to associate with ongoing notifications. */
    private MediaSession mMediaSession;

    /** The player state that the most recent notifcation was for. */
    private PlayerState mNotifiedPlayerState;

    /**
     * Keeps track of all subscriptions, so we can cancel all subscriptions for a client at once
     */
    final Map<ServiceCallback, ServiceCallbackList> callbacks = new ConcurrentHashMap<ServiceCallback, ServiceCallbackList>();

    @Override
    public void addClient(ServiceCallbackList callbackList, ServiceCallback item) {
        callbacks.put(item, callbackList);
    }

    @Override
    public void removeClient(ServiceCallback item) {
        callbacks.remove(item);
    }

    final CliClient cli = new CliClient(mEventBus);

    /**
     * Is scrobbling enabled?
     */
    private boolean scrobblingEnabled;

    /**
     * Was scrobbling enabled?
     */
    private boolean scrobblingPreviouslyEnabled;

    /** Whether to show an on-going notification when a track is not playing. */
    boolean mShowNotificationWhenNotPlaying;

    int mFadeInSecs;

    @Nullable
    String mUsername;

    @Nullable
    String mPassword;

    /** Map Player IDs to the {@link uk.org.ngo.squeezer.model.Player} with that ID. */
    private final Map<String, Player> mPlayers = new HashMap<String, Player>();

    /** The active player (the player to which commands are sent by default). */
    private final AtomicReference<Player> mActivePlayer = new AtomicReference<Player>();

    private static final String ACTION_NEXT_TRACK = "uk.org.ngo.squeezer.service.ACTION_NEXT_TRACK";
    private static final String ACTION_PREV_TRACK = "uk.org.ngo.squeezer.service.ACTION_PREV_TRACK";
    private static final String ACTION_PLAY = "uk.org.ngo.squeezer.service.ACTION_PLAY";
    private static final String ACTION_PAUSE = "uk.org.ngo.squeezer.service.ACTION_PAUSE";
    private static final String ACTION_CLOSE = "uk.org.ngo.squeezer.service.ACTION_CLOSE";

    /**
     * Thrown when the service is asked to send a command to the server before the server
     * handshake completes.
     */
    public static class HandshakeNotCompleteException extends IllegalStateException {
        public HandshakeNotCompleteException() {
            super();
        }

        public HandshakeNotCompleteException(String message) {
            super(message);
        }

        public HandshakeNotCompleteException(String message, Throwable cause) {
            super(message, cause);
        }

        public HandshakeNotCompleteException(Throwable cause) {
            super(cause);
        }
    }

    @Override
    public void onCreate() {
        super.onCreate();

        // Clear leftover notification in case this service previously got killed while playing
        NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
        nm.cancel(PLAYBACKSERVICE_STATUS);

        cachePreferences();

        setWifiLock(((WifiManager) getSystemService(Context.WIFI_SERVICE))
                .createWifiLock(WifiManager.WIFI_MODE_FULL, "Squeezer_WifiLock"));

        mEventBus.register(this, 1); // Get events before other subscribers
        cli.initialize();
    }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        try {
            if (intent != null && intent.getAction() != null) {
                if (intent.getAction().equals(ACTION_NEXT_TRACK)) {
                    squeezeService.nextTrack();
                } else if (intent.getAction().equals(ACTION_PREV_TRACK)) {
                    squeezeService.previousTrack();
                } else if (intent.getAction().equals(ACTION_PLAY)) {
                    squeezeService.play();
                } else if (intent.getAction().equals(ACTION_PAUSE)) {
                    squeezeService.pause();
                } else if (intent.getAction().equals(ACTION_CLOSE)) {
                    squeezeService.disconnect();
                }
            }
        } catch (Exception e) {

        }
        return START_STICKY;
    }

    /**
     * Cache the value of various preferences.
     */
    private void cachePreferences() {
        final SharedPreferences preferences = getSharedPreferences(Preferences.NAME, MODE_PRIVATE);
        scrobblingEnabled = preferences.getBoolean(Preferences.KEY_SCROBBLE_ENABLED, false);
        mFadeInSecs = preferences.getInt(Preferences.KEY_FADE_IN_SECS, 0);
        mShowNotificationWhenNotPlaying = preferences.getBoolean(Preferences.KEY_NOTIFY_OF_CONNECTION, false);
    }

    @Override
    public IBinder onBind(Intent intent) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            mMediaSession = new MediaSession(getApplicationContext(), "squeezer");
        }
        return (IBinder) squeezeService;
    }

    @Override
    public boolean onUnbind(Intent intent) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            if (mMediaSession != null) {
                mMediaSession.release();
            }
        }
        return super.onUnbind(intent);
    }

    @Override
    public void onDestroy() {
        super.onDestroy();
        disconnect();
        mEventBus.unregister(this);
    }

    void disconnect() {
        disconnect(false);
    }

    void disconnect(boolean isServerDisconnect) {
        cli.disconnect(isServerDisconnect && !mHandshakeComplete);
    }

    private String getActivePlayerId() {
        return (mActivePlayer.get() != null ? mActivePlayer.get().getId() : null);
    }

    @Nullable
    public PlayerState getPlayerState(String playerId) {
        Player player = mPlayers.get(playerId);

        if (player == null)
            return null;

        return player.getPlayerState();
    }

    /**
     * Send the specified command for the active player to the SqueezeboxServer
     *
     * @param command The command to send
     */
    public void sendActivePlayerCommand(final String command) {
        Player player = mActivePlayer.get();
        if (player == null) {
            return;
        }
        cli.sendPlayerCommand(player, command);
    }

    @Nullable
    public PlayerState getActivePlayerState() {
        if (mActivePlayer.get() == null)
            return null;

        return mActivePlayer.get().getPlayerState();
    }

    /**
     * The player state change might warrant a new subscription type (e.g., if the
     * player didn't have a sleep duration set, and now does).
     * @param event
     */
    public void onEvent(PlayerStateChanged event) {
        updatePlayerSubscription(event.player, calculateSubscriptionTypeFor(event.player));
    }

    /**
     * Updates the playing status of the current player.
     * <p>
     * Updates the Wi-Fi lock and ongoing status notification as necessary.
     */
    public void onEvent(PlayStatusChanged event) {
        if (event.player.equals(mActivePlayer.get())) {
            updateWifiLock(event.player.getPlayerState().isPlaying());
            updateOngoingNotification();
        }

        updatePlayerSubscription(event.player, calculateSubscriptionTypeFor(event.player));
    }

    /**
     * Change the player that is controlled by Squeezer (the "active" player).
     *
     * @param newActivePlayer The new active player. May be null, in which case no players
     *     are controlled.
     */
    void changeActivePlayer(@Nullable final Player newActivePlayer) {
        Player prevActivePlayer = mActivePlayer.get();

        // Do nothing if they player hasn't actually changed.
        if (prevActivePlayer == newActivePlayer) {
            return;
        }

        mActivePlayer.set(newActivePlayer);
        updateAllPlayerSubscriptionStates();

        Log.i(TAG, "Active player now: " + newActivePlayer);

        // If this is a new player then start an async fetch of its status.
        if (newActivePlayer != null) {
            cli.sendPlayerCommand(newActivePlayer, "status - 1 tags:" + SqueezeService.SONGTAGS);
        }

        // NOTE: this involves a write and can block (sqlite lookup via binder call), so
        // should be done off-thread, so we can process service requests & send our callback
        // as quickly as possible.
        mExecutor.execute(new Runnable() {
            @Override
            public void run() {
                final SharedPreferences preferences = Squeezer.getContext().getSharedPreferences(Preferences.NAME,
                        Squeezer.MODE_PRIVATE);
                SharedPreferences.Editor editor = preferences.edit();

                if (newActivePlayer == null) {
                    Log.v(TAG, "Clearing " + Preferences.KEY_LAST_PLAYER);
                    editor.remove(Preferences.KEY_LAST_PLAYER);
                } else {
                    Log.v(TAG, "Saving " + Preferences.KEY_LAST_PLAYER + "=" + newActivePlayer.getId());
                    editor.putString(Preferences.KEY_LAST_PLAYER, newActivePlayer.getId());
                }

                editor.commit();
            }
        });
    }

    /**
     * Adjusts the subscription to players' status updates.
     */
    private void updateAllPlayerSubscriptionStates() {
        // mPlayers might be modified by another thread, so copy the values.
        Collection<Player> players = mPlayers.values();
        for (Player player : players) {
            updatePlayerSubscription(player, calculateSubscriptionTypeFor(player));
        }
    }

    /**
     * Determine the correct status subscription type for the given player, based on
     * how frequently we need to know its status.
     */
    private @PlayerState.PlayerSubscriptionType String calculateSubscriptionTypeFor(Player player) {
        Player activePlayer = this.mActivePlayer.get();

        if (mEventBus.hasSubscriberForEvent(PlayerStateChanged.class)
                || (mEventBus.hasSubscriberForEvent(SongTimeChanged.class) && player.equals(activePlayer))) {
            if (player.equals(activePlayer)) {
                // If it's the active player then get second-to-second updates.
                return PlayerState.NOTIFY_REAL_TIME;
            } else {
                // For other players get updates only when the player status changes...
                // ... unless the player has a sleep duration set. In that case we need
                // real_time updates, as on_change events are not fired as the will_sleep_in
                // timer counts down.
                if (player.getPlayerState().getSleep() > 0) {
                    return PlayerState.NOTIFY_REAL_TIME;
                } else {
                    return PlayerState.NOTIFY_ON_CHANGE;
                }
            }
        } else {
            // Disable subscription for this player's status updates.
            return PlayerState.NOTIFY_NONE;
        }
    }

    /**
     * Manage subscription to a player's status updates.
     *
     * @param player player to manage.
     * @param playerSubscriptionType the new subscription type
     */
    private void updatePlayerSubscription(Player player,
            @NonNull @PlayerState.PlayerSubscriptionType String playerSubscriptionType) {
        PlayerState playerState = player.getPlayerState();

        // Do nothing if the player subscription type hasn't changed. This prevents sending a
        // subscription update "status" message which will be echoed back by the server and
        // trigger processing of the status message by the service.
        if (playerState != null) {
            if (playerState.getSubscriptionType().equals(playerSubscriptionType)) {
                return;
            }
        }

        cli.sendPlayerCommand(player, "status - 1 subscribe:" + playerSubscriptionType + " tags:" + SONGTAGS);
    }

    /**
     * Manages the state of any ongoing notification based on the player and connection state.
     */
    private void updateOngoingNotification() {
        Player activePlayer = this.mActivePlayer.get();
        PlayerState activePlayerState = getActivePlayerState();

        // Update scrobble state, if either we're currently scrobbling, or we
        // were (to catch the case where we started scrobbling a song, and the
        // user went in to settings to disable scrobbling).
        if (scrobblingEnabled || scrobblingPreviouslyEnabled) {
            scrobblingPreviouslyEnabled = scrobblingEnabled;
            Scrobble.scrobbleFromPlayerState(this, activePlayerState);
        }

        // If there's no active player then kill the notification and get out.
        // TODO: Have a "There are no connected players" notification text.
        if (activePlayer == null || activePlayerState == null) {
            clearOngoingNotification();
            return;
        }

        boolean playing = activePlayerState.isPlaying();

        // If the song is not playing and the user wants notifications only when playing then
        // kill the notification and get out.
        if (!playing && !mShowNotificationWhenNotPlaying) {
            clearOngoingNotification();
            return;
        }

        // If there's no current song then kill the notification and get out.
        // TODO: Have a "There's nothing playing" notification text.
        final Song currentSong = activePlayerState.getCurrentSong();
        if (currentSong == null) {
            clearOngoingNotification();
            return;
        }

        // Compare the current state with the state when the notification was last updated.
        // If there are no changes (same song, same playing state) then there's nothing to do.
        String songName = currentSong.getName();
        String albumName = currentSong.getAlbumName();
        String artistName = currentSong.getArtist();
        Uri url = currentSong.getArtworkUrl();
        String playerName = activePlayer.getName();

        if (mNotifiedPlayerState == null) {
            mNotifiedPlayerState = new PlayerState();
        } else {
            boolean lastPlaying = mNotifiedPlayerState.isPlaying();
            Song lastNotifiedSong = mNotifiedPlayerState.getCurrentSong();

            // No change in state
            if (playing == lastPlaying && currentSong.equals(lastNotifiedSong)) {
                return;
            }
        }

        mNotifiedPlayerState.setCurrentSong(currentSong);
        mNotifiedPlayerState.setPlayStatus(activePlayerState.getPlayStatus());
        final NotificationManagerCompat nm = NotificationManagerCompat.from(this);

        PendingIntent nextPendingIntent = getPendingIntent(ACTION_NEXT_TRACK);
        PendingIntent prevPendingIntent = getPendingIntent(ACTION_PREV_TRACK);
        PendingIntent playPendingIntent = getPendingIntent(ACTION_PLAY);
        PendingIntent pausePendingIntent = getPendingIntent(ACTION_PAUSE);
        PendingIntent closePendingIntent = getPendingIntent(ACTION_CLOSE);

        Intent showNowPlaying = new Intent(this, NowPlayingActivity.class)
                .setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_REORDER_TO_FRONT);
        PendingIntent pIntent = PendingIntent.getActivity(this, 0, showNowPlaying, 0);
        Notification notification;

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            final Notification.Builder builder = new Notification.Builder(this);
            builder.setContentIntent(pIntent);
            builder.setSmallIcon(R.drawable.squeezer_notification);
            builder.setVisibility(Notification.VISIBILITY_PUBLIC);
            builder.setShowWhen(false);
            builder.setContentTitle(songName);
            builder.setContentText(albumName);
            builder.setSubText(playerName);
            builder.setStyle(new Notification.MediaStyle().setShowActionsInCompactView(1, 2)
                    .setMediaSession(mMediaSession.getSessionToken()));

            final MediaMetadata.Builder metaBuilder = new MediaMetadata.Builder();
            metaBuilder.putString(MediaMetadata.METADATA_KEY_ARTIST, artistName);
            metaBuilder.putString(MediaMetadata.METADATA_KEY_ALBUM, albumName);
            metaBuilder.putString(MediaMetadata.METADATA_KEY_TITLE, songName);
            mMediaSession.setMetadata(metaBuilder.build());

            // Don't set an ongoing notification, otherwise wearable's won't show it.
            builder.setOngoing(false);

            builder.setDeleteIntent(closePendingIntent);
            if (playing) {
                builder.addAction(
                        new Notification.Action(R.drawable.ic_action_previous, "Previous", prevPendingIntent))
                        .addAction(new Notification.Action(R.drawable.ic_action_pause, "Pause", pausePendingIntent))
                        .addAction(new Notification.Action(R.drawable.ic_action_next, "Next", nextPendingIntent));
            } else {
                builder.addAction(
                        new Notification.Action(R.drawable.ic_action_previous, "Previous", prevPendingIntent))
                        .addAction(new Notification.Action(R.drawable.ic_action_play, "Play", playPendingIntent))
                        .addAction(new Notification.Action(R.drawable.ic_action_next, "Next", nextPendingIntent));
            }

            ImageFetcher.getInstance(this).loadImage(url,
                    getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_width),
                    getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_height),
                    new ImageWorker.ImageWorkerCallback() {
                        @Override
                        @TargetApi(Build.VERSION_CODES.LOLLIPOP)
                        public void process(Object data, @Nullable Bitmap bitmap) {
                            if (bitmap == null) {
                                bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.icon_album_noart);
                            }

                            metaBuilder.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, bitmap);
                            metaBuilder.putBitmap(MediaMetadata.METADATA_KEY_ART, bitmap);
                            mMediaSession.setMetadata(metaBuilder.build());
                            builder.setLargeIcon(bitmap);
                            nm.notify(PLAYBACKSERVICE_STATUS, builder.build());
                        }
                    });
        } else {
            NotificationCompat.Builder builder = new NotificationCompat.Builder(this);

            builder.setOngoing(true);
            builder.setCategory(NotificationCompat.CATEGORY_SERVICE);
            builder.setSmallIcon(R.drawable.squeezer_notification);

            RemoteViews normalView = new RemoteViews(this.getPackageName(), R.layout.notification_player_normal);
            RemoteViews expandedView = new RemoteViews(this.getPackageName(),
                    R.layout.notification_player_expanded);

            normalView.setOnClickPendingIntent(R.id.next, nextPendingIntent);

            expandedView.setOnClickPendingIntent(R.id.previous, prevPendingIntent);
            expandedView.setOnClickPendingIntent(R.id.next, nextPendingIntent);

            builder.setContent(normalView);

            normalView.setTextViewText(R.id.trackname, songName);
            normalView.setTextViewText(R.id.albumname, albumName);

            expandedView.setTextViewText(R.id.trackname, songName);
            expandedView.setTextViewText(R.id.albumname, albumName);
            expandedView.setTextViewText(R.id.player_name, playerName);

            if (playing) {
                normalView.setImageViewResource(R.id.pause, R.drawable.ic_action_pause);
                normalView.setOnClickPendingIntent(R.id.pause, pausePendingIntent);

                expandedView.setImageViewResource(R.id.pause, R.drawable.ic_action_pause);
                expandedView.setOnClickPendingIntent(R.id.pause, pausePendingIntent);
            } else {
                normalView.setImageViewResource(R.id.pause, R.drawable.ic_action_play);
                normalView.setOnClickPendingIntent(R.id.pause, playPendingIntent);

                expandedView.setImageViewResource(R.id.pause, R.drawable.ic_action_play);
                expandedView.setOnClickPendingIntent(R.id.pause, playPendingIntent);
            }

            builder.setContentTitle(songName);
            builder.setContentText(getString(R.string.notification_playing_text, playerName));
            builder.setContentIntent(pIntent);

            notification = builder.build();
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
                notification.bigContentView = expandedView;
            }

            nm.notify(PLAYBACKSERVICE_STATUS, notification);

            ImageFetcher.getInstance(this).loadImage(this, url, normalView, R.id.album,
                    getResources().getDimensionPixelSize(R.dimen.album_art_icon_normal_notification_width),
                    getResources().getDimensionPixelSize(R.dimen.album_art_icon_normal_notification_height), nm,
                    PLAYBACKSERVICE_STATUS, notification);
            ImageFetcher.getInstance(this).loadImage(this, url, expandedView, R.id.album,
                    getResources().getDimensionPixelSize(R.dimen.album_art_icon_expanded_notification_width),
                    getResources().getDimensionPixelSize(R.dimen.album_art_icon_expanded_notification_height), nm,
                    PLAYBACKSERVICE_STATUS, notification);
        }
    }

    /**
     * @param action The action to be performed.
     * @return A new {@link PendingIntent} for {@literal action} that will update any existing
     *     intents that use the same action.
     */
    @NonNull
    private PendingIntent getPendingIntent(@NonNull String action) {
        Intent intent = new Intent(this, SqueezeService.class);
        intent.setAction(action);
        return PendingIntent.getService(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);
    }

    private void clearOngoingNotification() {
        NotificationManagerCompat nm = NotificationManagerCompat.from(this);
        nm.cancel(PLAYBACKSERVICE_STATUS);
        mNotifiedPlayerState = null;
    }

    public void onEvent(ConnectionChanged event) {
        if (event.connectionState == ConnectionState.DISCONNECTED) {
            mPlayers.clear();
            mEventBus.removeAllStickyEvents();
            mActivePlayer.set(null);
            mHandshakeComplete = false;
            clearOngoingNotification();
        }
    }

    public void onEvent(HandshakeComplete event) {
        mHandshakeComplete = true;
        strings();
    }

    public void onEvent(MusicChanged event) {
        if (event.player.equals(mActivePlayer.get())) {
            updateOngoingNotification();
        }
    }

    public void onEvent(PlayersChanged event) {
        mPlayers.clear();
        mPlayers.putAll(event.players);

        // Figure out the new active player, let everyone know.
        changeActivePlayer(getPreferredPlayer());
    }

    /**
     * @return The player that should be chosen as the (new) active player. This is either the
     *     last active player (if known), the first player the server knows about if there are
     *     connected players, or null if there are no connected players.
     */
    private @Nullable Player getPreferredPlayer() {
        final SharedPreferences preferences = Squeezer.getContext().getSharedPreferences(Preferences.NAME,
                Context.MODE_PRIVATE);
        final String lastConnectedPlayer = preferences.getString(Preferences.KEY_LAST_PLAYER, null);
        Log.i(TAG, "lastConnectedPlayer was: " + lastConnectedPlayer);

        Collection<Player> players = mPlayers.values();
        Log.i(TAG, "mPlayers empty?: " + mPlayers.isEmpty());
        for (Player player : players) {
            if (player.getId().equals(lastConnectedPlayer)) {
                return player;
            }
        }
        return !players.isEmpty() ? players.iterator().next() : null;
    }

    /* Start an asynchronous fetch of the squeezeservers localized strings */
    private void strings() {
        cli.sendCommandImmediately("getstring " + ServerString.values()[0].name());
    }

    /** A download request will be passed to the download manager for each song called back to this */
    private final IServiceItemListCallback<Song> songDownloadCallback = new IServiceItemListCallback<Song>() {
        @Override
        public void onItemsReceived(int count, int start, Map<String, String> parameters, List<Song> items,
                Class<Song> dataType) {
            for (Song item : items) {
                downloadSong(item.getDownloadUrl(), item.getName(), item.getUrl());
            }
        }

        @Override
        public Object getClient() {
            return this;
        }
    };

    /**
     * For each item called to this:
     * If it is a folder: recursive lookup items in the folder
     * If is is a track: Enqueue a download request to the download manager
     */
    private final IServiceItemListCallback<MusicFolderItem> musicFolderDownloadCallback = new IServiceItemListCallback<MusicFolderItem>() {
        @Override
        public void onItemsReceived(int count, int start, Map<String, String> parameters,
                List<MusicFolderItem> items, Class<MusicFolderItem> dataType) {
            for (MusicFolderItem item : items) {
                squeezeService.downloadItem(item);
            }
        }

        @Override
        public Object getClient() {
            return this;
        }
    };

    @TargetApi(Build.VERSION_CODES.GINGERBREAD)
    private void downloadSong(@NonNull Uri url, String title, @NonNull Uri serverUrl) {
        if (url.equals(Uri.EMPTY)) {
            return;
        }

        // If running on Gingerbread or greater use the Download Manager
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.GINGERBREAD) {
            DownloadManager downloadManager = (DownloadManager) getSystemService(DOWNLOAD_SERVICE);
            DownloadDatabase downloadDatabase = new DownloadDatabase(this);
            String localPath = getLocalFile(serverUrl);
            String tempFile = UUID.randomUUID().toString();
            String credentials = mUsername + ":" + mPassword;
            String base64EncodedCredentials = Base64.encodeToString(credentials.getBytes(), Base64.NO_WRAP);
            DownloadManager.Request request = new DownloadManager.Request(url).setTitle(title)
                    .setDestinationInExternalFilesDir(this, Environment.DIRECTORY_MUSIC, tempFile)
                    .setVisibleInDownloadsUi(false)
                    .addRequestHeader("Authorization", "Basic " + base64EncodedCredentials);
            long downloadId = downloadManager.enqueue(request);

            Crashlytics.log("Registering new download");
            Crashlytics.log("downloadId: " + downloadId);
            Crashlytics.log("tempFile: " + tempFile);
            Crashlytics.log("localPath: " + localPath);

            if (!downloadDatabase.registerDownload(downloadId, tempFile, localPath)) {
                Crashlytics.log(Log.WARN, TAG, "Could not register download entry for: " + downloadId);
                downloadManager.remove(downloadId);
            }
        }
    }

    /**
     * Tries to get the path relative to the server music library.
     * <p>
     * If this is not possible resort to the last path segment of the server path.
     * In both cases replace dangerous characters by safe ones.
     */
    private String getLocalFile(@NonNull Uri serverUrl) {
        String serverPath = serverUrl.getPath();
        String mediaDir = null;
        String path = null;
        for (String dir : cli.getMediaDirs()) {
            if (serverPath.startsWith(dir)) {
                mediaDir = dir;
                break;
            }
        }
        if (mediaDir != null)
            path = serverPath.substring(mediaDir.length(), serverPath.length());
        else
            path = serverUrl.getLastPathSegment();

        // Convert VFAT-unfriendly characters to "_".
        return path.replaceAll("[?<>\\\\:*|\"]", "_");
    }

    private WifiManager.WifiLock wifiLock;

    void setWifiLock(WifiManager.WifiLock wifiLock) {
        this.wifiLock = wifiLock;
    }

    void updateWifiLock(boolean state) {
        // TODO: this might be running in the wrong thread.  Is wifiLock thread-safe?
        if (state && !wifiLock.isHeld()) {
            Log.v(TAG, "Locking wifi while playing.");
            wifiLock.acquire();
        }
        if (!state && wifiLock.isHeld()) {
            Log.v(TAG, "Unlocking wifi.");
            try {
                wifiLock.release();
                // Seen a crash here with:
                //
                // Permission Denial: broadcastIntent() requesting a sticky
                // broadcast
                // from pid=29506, uid=10061 requires
                // android.permission.BROADCAST_STICKY
                //
                // Catching the exception (which seems harmless) seems better
                // than requesting an additional permission.

                // Seen a crash here with
                //
                // java.lang.RuntimeException: WifiLock under-locked
                // Squeezer_WifiLock
                //
                // Both crashes occurred when the wifi was disabled, on HTC Hero
                // devices running 2.1-update1.
            } catch (SecurityException e) {
                Log.v(TAG, "Caught odd SecurityException releasing wifilock");
            }
        }
    }

    private final ISqueezeService squeezeService = new SqueezeServiceBinder();

    private class SqueezeServiceBinder extends Binder implements ISqueezeService {

        @Override
        @NonNull
        public EventBus getEventBus() {
            return mEventBus;
        }

        @Override
        public void adjustVolumeTo(Player player, int newVolume) {
            cli.sendPlayerCommand(player, "mixer volume " + Math.min(100, Math.max(0, newVolume)));
        }

        @Override
        public void adjustVolumeTo(int newVolume) {
            sendActivePlayerCommand("mixer volume " + Math.min(100, Math.max(0, newVolume)));
        }

        @Override
        public void adjustVolumeBy(int delta) {
            if (delta > 0) {
                sendActivePlayerCommand("mixer volume %2B" + delta);
            } else if (delta < 0) {
                sendActivePlayerCommand("mixer volume " + delta);
            }
        }

        @Override
        public boolean isConnected() {
            return cli.isConnected();
        }

        @Override
        public boolean isConnectInProgress() {
            return cli.isConnectInProgress();
        }

        @Override
        public void startConnect(String hostPort, String userName, String password) {
            mUsername = userName;
            mPassword = password;
            cli.startConnect(SqueezeService.this, hostPort, userName, password);
        }

        @Override
        public void disconnect() {
            if (!isConnected()) {
                return;
            }
            SqueezeService.this.disconnect();
        }

        @Override
        public void powerOn() {
            sendActivePlayerCommand("power 1");
        }

        @Override
        public void powerOff() {
            sendActivePlayerCommand("power 0");
        }

        @Override
        public void togglePower(Player player) {
            cli.sendPlayerCommand(player, "power");
        }

        @Override
        public void playerRename(Player player, String newName) {
            cli.sendPlayerCommand(player, "name " + Util.encode(newName));
        }

        @Override
        public void sleep(Player player, int duration) {
            cli.sendPlayerCommand(player, "sleep " + duration);
        }

        @Override
        public void syncPlayerToPlayer(@NonNull Player slave, @NonNull String masterId) {
            Player master = mPlayers.get(masterId);
            cli.sendPlayerCommand(master, "sync " + Util.encode(slave.getId()));
        }

        @Override
        public void unsyncPlayer(@NonNull Player player) {
            cli.sendPlayerCommand(player, "sync -");
        }

        @Override
        @Nullable
        public PlayerState getActivePlayerState() {
            if (mActivePlayer == null) {
                return null;
            }
            Player activePlayer = mActivePlayer.get();
            if (activePlayer == null) {
                return null;
            }

            return activePlayer.getPlayerState();
        }

        @Override
        @Nullable
        public PlayerState getPlayerState(String playerId) {
            Player player = mPlayers.get(playerId);
            if (player == null) {
                return null;
            }

            return player.getPlayerState();
        }

        /**
         * Issues a query for given player preference.
         *
         * @param playerPref
         */
        @Override
        public void playerPref(@Player.Pref.Name String playerPref) {
            playerPref(playerPref, "?");
        }

        @Override
        public void playerPref(@Player.Pref.Name String playerPref, String value) {
            sendActivePlayerCommand("playerpref " + playerPref + " " + value);
        }

        @Override
        public boolean canPowerOn() {
            Player activePlayer = getActivePlayer();

            if (activePlayer == null) {
                return false;
            } else {
                PlayerState playerState = activePlayer.getPlayerState();
                return canPower() && activePlayer.getConnected() && playerState != null
                        && !playerState.isPoweredOn();
            }
        }

        @Override
        public boolean canPowerOff() {
            Player activePlayer = getActivePlayer();

            if (activePlayer == null) {
                return false;
            } else {
                PlayerState playerState = activePlayer.getPlayerState();
                return canPower() && activePlayer.getConnected() && playerState != null
                        && playerState.isPoweredOn();
            }
        }

        private boolean canPower() {
            Player player = mActivePlayer.get();
            return cli.isConnected() && player != null && player.isCanpoweroff();
        }

        @Override
        public String preferredAlbumSort() throws HandshakeNotCompleteException {
            if (!mHandshakeComplete) {
                throw new HandshakeNotCompleteException("Handshake with server has not completed.");
            }
            return cli.getPreferredAlbumSort();
        }

        @Override
        public void setPreferredAlbumSort(String preferredAlbumSort) {
            if (isConnected()) {
                cli.sendCommand("pref jivealbumsort " + Util.encode(preferredAlbumSort));
            }
        }

        private String fadeInSecs() {
            return mFadeInSecs > 0 ? " " + mFadeInSecs : "";
        }

        @Override
        public boolean togglePausePlay() {
            if (!isConnected()) {
                return false;
            }

            PlayerState activePlayerState = getActivePlayerState();

            // May be null (e.g., connected to a server with no connected
            // players. TODO: Handle this better, since it's not obvious in the
            // UI.
            if (activePlayerState == null)
                return false;

            @PlayerState.PlayState
            String playStatus = activePlayerState.getPlayStatus();

            // May be null -- race condition when connecting to a server that
            // has a player. Squeezer knows the player exists, but has not yet
            // determined its state.
            if (playStatus == null)
                return false;

            if (playStatus.equals(PlayerState.PLAY_STATE_PLAY)) {
                // NOTE: we never send ambiguous "pause" toggle commands (without the '1')
                // because then we'd get confused when they came back in to us, not being
                // able to differentiate ours coming back on the listen channel vs. those
                // of those idiots at the dinner party messing around.
                sendActivePlayerCommand("pause 1");
                return true;
            }

            if (playStatus.equals(PlayerState.PLAY_STATE_STOP)) {
                sendActivePlayerCommand("play" + fadeInSecs());
                return true;
            }

            if (playStatus.equals(PlayerState.PLAY_STATE_PAUSE)) {
                sendActivePlayerCommand("pause 0" + fadeInSecs());
                return true;
            }

            return true;
        }

        @Override
        public boolean play() {
            if (!isConnected()) {
                return false;
            }
            sendActivePlayerCommand("play" + fadeInSecs());
            return true;
        }

        @Override
        public boolean pause() {
            if (!isConnected()) {
                return false;
            }
            sendActivePlayerCommand("pause 1" + fadeInSecs());
            return true;
        }

        @Override
        public boolean stop() {
            if (!isConnected()) {
                return false;
            }
            sendActivePlayerCommand("stop");
            return true;
        }

        @Override
        public boolean nextTrack() {
            if (!isConnected() || !isPlaying()) {
                return false;
            }
            sendActivePlayerCommand("button jump_fwd");
            return true;
        }

        @Override
        public boolean previousTrack() {
            if (!isConnected() || !isPlaying()) {
                return false;
            }
            sendActivePlayerCommand("button jump_rew");
            return true;
        }

        @Override
        public boolean toggleShuffle() {
            if (!isConnected()) {
                return false;
            }
            sendActivePlayerCommand("playlist shuffle");
            return true;
        }

        @Override
        public boolean toggleRepeat() {
            if (!isConnected()) {
                return false;
            }
            sendActivePlayerCommand("playlist repeat");
            return true;
        }

        @Override
        public boolean playlistControl(@BaseActivity.PlaylistControlCmd String cmd, PlaylistItem playlistItem) {
            if (!isConnected()) {
                return false;
            }

            sendActivePlayerCommand("playlistcontrol cmd:" + cmd + " " + playlistItem.getPlaylistParameter());
            return true;
        }

        @Override
        public boolean randomPlay(@RandomplayActivity.RandomplayType String type)
                throws HandshakeNotCompleteException {
            if (!mHandshakeComplete) {
                throw new HandshakeNotCompleteException("Handshake with server has not completed.");
            }
            sendActivePlayerCommand("randomplay " + type);
            return true;
        }

        /**
         * Start playing the song in the current playlist at the given index.
         *
         * @param index the index to jump to
         */
        @Override
        public boolean playlistIndex(int index) {
            if (!isConnected()) {
                return false;
            }
            sendActivePlayerCommand("playlist index " + index + fadeInSecs());
            return true;
        }

        @Override
        public boolean playlistRemove(int index) {
            if (!isConnected()) {
                return false;
            }
            sendActivePlayerCommand("playlist delete " + index);
            return true;
        }

        @Override
        public boolean playlistMove(int fromIndex, int toIndex) {
            if (!isConnected()) {
                return false;
            }
            sendActivePlayerCommand("playlist move " + fromIndex + " " + toIndex);
            return true;
        }

        @Override
        public boolean playlistClear() {
            if (!isConnected()) {
                return false;
            }
            sendActivePlayerCommand("playlist clear");
            return true;
        }

        @Override
        public boolean playlistSave(String name) {
            if (!isConnected()) {
                return false;
            }
            sendActivePlayerCommand("playlist save " + Util.encode(name));
            return true;
        }

        @Override
        public boolean pluginPlaylistControl(Plugin plugin,
                @PluginItemListActivity.PluginPlaylistControlCmd String cmd, String itemId) {
            if (!isConnected()) {
                return false;
            }
            sendActivePlayerCommand(plugin.getId() + " playlist " + cmd + " item_id:" + itemId);
            return true;

        }

        private boolean isPlaying() {
            PlayerState playerState = getActivePlayerState();
            return playerState != null && playerState.isPlaying();
        }

        /**
         * Change the player that is controlled by Squeezer (the "active" player).
         *
         * @param newActivePlayer May be null, in which case no players are controlled.
         */
        @Override
        public void setActivePlayer(@Nullable final Player newActivePlayer) {
            changeActivePlayer(newActivePlayer);
        }

        @Override
        @Nullable
        public Player getActivePlayer() {
            return mActivePlayer.get();
        }

        @Override
        public List<Player> getPlayers() {
            // TODO: Return a Collection, instead of casting? Or return an ImmutableList?
            return (List<Player>) new ArrayList<Player>(mPlayers.values());
        }

        @Override
        public java.util.Collection<Player> getConnectedPlayers() {
            return mPlayers.values();
        }

        @Override
        public PlayerState getPlayerState() {
            return getActivePlayerState();
        }

        /**
         * @return null if there is no active player, otherwise the name of the current playlist,
         *     which may be the empty string.
         */
        @Override
        @Nullable
        public String getCurrentPlaylist() {
            PlayerState playerState = getActivePlayerState();

            if (playerState == null)
                return null;

            return playerState.getCurrentPlaylist();
        }

        @Override
        public boolean setSecondsElapsed(int seconds) {
            if (!isConnected()) {
                return false;
            }
            if (seconds < 0) {
                return false;
            }

            sendActivePlayerCommand("time " + seconds);

            return true;
        }

        @Override
        public void preferenceChanged(String key) {
            Log.i(TAG, "Preference changed: " + key);
            cachePreferences();

            if (Preferences.KEY_NOTIFY_OF_CONNECTION.equals(key)) {
                updateOngoingNotification();
                return;
            }

            // If the server address changed then disconnect.
            if (key.startsWith(Preferences.KEY_SERVER_ADDRESS)) {
                disconnect();
                return;
            }
        }

        @Override
        public void cancelItemListRequests(Object client) {
            cli.cancelClientRequests(client);
        }

        @Override
        public void cancelSubscriptions(Object client) {
            for (Entry<ServiceCallback, ServiceCallbackList> entry : callbacks.entrySet()) {
                if (entry.getKey().getClient() == client) {
                    entry.getValue().unregister(entry.getKey());
                }
            }
            updateAllPlayerSubscriptionStates();
        }

        // XXX: Is this method needed? What calls it?
        @Override
        public void players() throws HandshakeNotCompleteException {
            if (!mHandshakeComplete) {
                throw new HandshakeNotCompleteException("Handshake with server has not completed.");
            }
            //fetchPlayers();
        }

        @Override
        public void alarms(int start, IServiceItemListCallback<Alarm> callback) {
            if (!isConnected()) {
                return;
            }
            List<String> parameters = new ArrayList<String>();
            parameters.add("filter:all");
            cli.requestPlayerItems(mActivePlayer.get(), "alarms", start, parameters, callback);
        }

        @Override
        public void alarmPlaylists(IServiceItemListCallback<AlarmPlaylist> callback) {
            if (!isConnected()) {
                return;
            }
            // The LMS documentation states that
            // The "alarm playlists" returns all the playlists, sounds, favorites etc. available to alarms.
            // This will however return only one playlist: the current playlist.
            // Inspection of the LMS code reveals that the "alarm playlists" command takes the
            // customary <start> and <itemsPerResponse> parameters, but these are interpreted as
            // categories (eg. Favorites, Natural Sounds etc.), but the returned list is flattened,
            // i.e. contains all items of the requested categories.
            // So we order all playlists like below, hoping there are no more than 99 categories.
            cli.requestItems("alarm playlists", 0, 99, callback);
        }

        @Override
        public void alarmAdd(int time) {
            if (!isConnected()) {
                return;
            }
            sendActivePlayerCommand("alarm add time:" + time);
        }

        @Override
        public void alarmDelete(String id) {
            if (!isConnected()) {
                return;
            }
            sendActivePlayerCommand("alarm delete id:" + Util.encode(id));
        }

        @Override
        public void alarmSetTime(String id, int time) {
            if (!isConnected()) {
                return;
            }
            sendActivePlayerCommand("alarm update id:" + Util.encode(id) + " time:" + time);
        }

        @Override
        public void alarmAddDay(String id, int day) {
            sendActivePlayerCommand("alarm update id:" + Util.encode(id) + " dowAdd:" + day);
        }

        @Override
        public void alarmRemoveDay(String id, int day) {
            sendActivePlayerCommand("alarm update id:" + Util.encode(id) + " dowDel:" + day);
        }

        @Override
        public void alarmEnable(String id, boolean enabled) {
            sendActivePlayerCommand("alarm update id:" + Util.encode(id) + " enabled:" + (enabled ? "1" : "0"));
        }

        @Override
        public void alarmRepeat(String id, boolean repeat) {
            sendActivePlayerCommand("alarm update id:" + Util.encode(id) + " repeat:" + (repeat ? "1" : "0"));
        }

        @Override
        public void alarmSetPlaylist(String id, AlarmPlaylist playlist) {
            String url = "".equals(playlist.getId()) ? "0" : playlist.getId();
            sendActivePlayerCommand("alarm update id:" + Util.encode(id) + " url:" + Util.encode(url));
        }

        /* Start an async fetch of the SqueezeboxServer's albums, which are matching the given parameters */
        @Override
        public void albums(IServiceItemListCallback<Album> callback, int start, String sortOrder,
                String searchString, FilterItem... filters) throws HandshakeNotCompleteException {
            if (!mHandshakeComplete) {
                throw new HandshakeNotCompleteException("Handshake with server has not completed.");
            }
            List<String> parameters = new ArrayList<String>();
            parameters.add("tags:" + ALBUMTAGS);
            parameters.add("sort:" + sortOrder);
            if (searchString != null && searchString.length() > 0) {
                parameters.add("search:" + searchString);
            }
            for (FilterItem filter : filters)
                if (filter != null)
                    parameters.add(filter.getFilterParameter());
            cli.requestItems("albums", start, parameters, callback);
        }

        /* Start an async fetch of the SqueezeboxServer's artists */
        @Override
        public void artists(IServiceItemListCallback<Artist> callback, int start, String searchString,
                FilterItem... filters) throws HandshakeNotCompleteException {
            if (!mHandshakeComplete) {
                throw new HandshakeNotCompleteException("Handshake with server has not completed.");
            }
            List<String> parameters = new ArrayList<String>();
            if (searchString != null && searchString.length() > 0) {
                parameters.add("search:" + searchString);
            }
            for (FilterItem filter : filters)
                if (filter != null)
                    parameters.add(filter.getFilterParameter());
            cli.requestItems("artists", start, parameters, callback);
        }

        /* Start an async fetch of the SqueezeboxServer's years */
        @Override
        public void years(int start, IServiceItemListCallback<Year> callback) throws HandshakeNotCompleteException {
            if (!mHandshakeComplete) {
                throw new HandshakeNotCompleteException("Handshake with server has not completed.");
            }
            cli.requestItems("years", start, callback);
        }

        /* Start an async fetch of the SqueezeboxServer's genres */
        @Override
        public void genres(int start, String searchString, IServiceItemListCallback<Genre> callback)
                throws HandshakeNotCompleteException {
            if (!mHandshakeComplete) {
                throw new HandshakeNotCompleteException("Handshake with server has not completed.");
            }
            List<String> parameters = new ArrayList<String>();
            if (searchString != null && searchString.length() > 0) {
                parameters.add("search:" + searchString);
            }
            cli.requestItems("genres", start, parameters, callback);
        }

        /**
         * Starts an async fetch of the contents of a SqueezerboxServer's music
         * folders in the given folderId.
         * <p>
         * folderId may be null, in which case the contents of the root music
         * folder are returned.
         * <p>
         * Results are returned through the given callback.
         *
         * @param start Where in the list of folders to start.
         * @param musicFolderItem The folder to view.
         * @param callback Results will be returned through this
         */
        @Override
        public void musicFolders(int start, MusicFolderItem musicFolderItem,
                IServiceItemListCallback<MusicFolderItem> callback) throws HandshakeNotCompleteException {
            if (!mHandshakeComplete) {
                throw new HandshakeNotCompleteException("Handshake with server has not completed.");
            }

            List<String> parameters = new ArrayList<String>();

            parameters.add("tags:u");//TODO only available from version 7.6 so instead keep track of path
            if (musicFolderItem != null) {
                parameters.add(musicFolderItem.getFilterParameter());
            }

            cli.requestItems("musicfolder", start, parameters, callback);
        }

        /* Start an async fetch of the SqueezeboxServer's songs */
        @Override
        public void songs(IServiceItemListCallback<Song> callback, int start, String sortOrder, String searchString,
                FilterItem... filters) throws HandshakeNotCompleteException {
            if (!mHandshakeComplete) {
                throw new HandshakeNotCompleteException("Handshake with server has not completed.");
            }
            List<String> parameters = new ArrayList<String>();
            parameters.add("tags:" + SONGTAGS);
            parameters.add("sort:" + sortOrder);
            if (searchString != null && searchString.length() > 0) {
                parameters.add("search:" + searchString);
            }
            for (FilterItem filter : filters)
                if (filter != null)
                    parameters.add(filter.getFilterParameter());
            cli.requestItems("songs", start, parameters, callback);
        }

        /* Start an async fetch of the SqueezeboxServer's current playlist */
        @Override
        public void currentPlaylist(int start, IServiceItemListCallback<Song> callback)
                throws HandshakeNotCompleteException {
            if (!mHandshakeComplete) {
                throw new HandshakeNotCompleteException("Handshake with server has not completed.");
            }
            cli.requestPlayerItems(mActivePlayer.get(), "status", start, Arrays.asList("tags:" + SONGTAGS),
                    callback);
        }

        /* Start an async fetch of the songs of the supplied playlist */
        @Override
        public void playlistSongs(int start, Playlist playlist, IServiceItemListCallback<Song> callback)
                throws HandshakeNotCompleteException {
            if (!mHandshakeComplete) {
                throw new HandshakeNotCompleteException("Handshake with server has not completed.");
            }
            cli.requestItems("playlists tracks", start,
                    Arrays.asList(playlist.getFilterParameter(), "tags:" + SONGTAGS), callback);
        }

        /* Start an async fetch of the SqueezeboxServer's playlists */
        @Override
        public void playlists(int start, IServiceItemListCallback<Playlist> callback)
                throws HandshakeNotCompleteException {
            if (!mHandshakeComplete) {
                throw new HandshakeNotCompleteException("Handshake with server has not completed.");
            }
            cli.requestItems("playlists", start, callback);
        }

        @Override
        public boolean playlistsDelete(Playlist playlist) {
            if (!isConnected()) {
                return false;
            }
            cli.sendCommand("playlists delete " + playlist.getFilterParameter());
            return true;
        }

        @Override
        public boolean playlistsMove(Playlist playlist, int index, int toindex) {
            if (!isConnected()) {
                return false;
            }
            cli.sendCommand("playlists edit cmd:move " + playlist.getFilterParameter() + " index:" + index
                    + " toindex:" + toindex);
            return true;
        }

        @Override
        public boolean playlistsNew(String name) {
            if (!isConnected()) {
                return false;
            }
            cli.sendCommand("playlists new name:" + Util.encode(name));
            return true;
        }

        @Override
        public boolean playlistsRemove(Playlist playlist, int index) {
            if (!isConnected()) {
                return false;
            }
            cli.sendCommand("playlists edit cmd:delete " + playlist.getFilterParameter() + " index:" + index);
            return true;
        }

        @Override
        public boolean playlistsRename(Playlist playlist, String newname) {
            if (!isConnected()) {
                return false;
            }
            cli.sendCommand("playlists rename " + playlist.getFilterParameter() + " dry_run:1 newname:"
                    + Util.encode(newname));
            return true;
        }

        /* Start an asynchronous search of the SqueezeboxServer's library */
        @Override
        public void search(int start, String searchString, IServiceItemListCallback itemListCallback)
                throws HandshakeNotCompleteException {
            if (!mHandshakeComplete) {
                throw new HandshakeNotCompleteException("Handshake with server has not completed.");
            }

            AlbumViewDialog.AlbumsSortOrder albumSortOrder = AlbumViewDialog.AlbumsSortOrder
                    .valueOf(preferredAlbumSort());

            artists(itemListCallback, start, searchString);
            albums(itemListCallback, start, albumSortOrder.name().replace("__", ""), searchString);
            genres(start, searchString, itemListCallback);
            songs(itemListCallback, start, SongViewDialog.SongsSortOrder.title.name(), searchString);
        }

        /* Start an asynchronous fetch of the squeezeservers radio type plugins */
        @Override
        public void radios(int start, IServiceItemListCallback<Plugin> callback)
                throws HandshakeNotCompleteException {
            if (!mHandshakeComplete) {
                throw new HandshakeNotCompleteException("Handshake with server has not completed.");
            }
            cli.requestItems("radios", start, callback);
        }

        /* Start an asynchronous fetch of the squeezeservers radio application plugins */
        @Override
        public void apps(int start, IServiceItemListCallback<Plugin> callback)
                throws HandshakeNotCompleteException {
            if (!mHandshakeComplete) {
                throw new HandshakeNotCompleteException("Handshake with server has not completed.");
            }
            cli.requestItems("apps", start, callback);
        }

        /* Start an asynchronous fetch of the squeezeservers items of the given type */
        @Override
        public void pluginItems(int start, Plugin plugin, PluginItem parent, String search,
                IServiceItemListCallback<PluginItem> callback) throws HandshakeNotCompleteException {
            if (!mHandshakeComplete) {
                throw new HandshakeNotCompleteException("Handshake with server has not completed.");
            }
            List<String> parameters = new ArrayList<String>();
            if (parent != null) {
                parameters.add("item_id:" + parent.getId());
            }
            if (search != null && search.length() > 0) {
                parameters.add("search:" + search);
            }
            cli.requestPlayerItems(mActivePlayer.get(), plugin.getId() + " items", start, parameters, callback);
        }

        @Override
        public void downloadItem(FilterItem item) throws HandshakeNotCompleteException {
            if (item instanceof Song) {
                Song song = (Song) item;
                if (!song.isRemote()) {
                    downloadSong(song.getDownloadUrl(), song.getName(), song.getUrl());
                }
            } else if (item instanceof Playlist) {
                playlistSongs(-1, (Playlist) item, songDownloadCallback);
            } else if (item instanceof MusicFolderItem) {
                MusicFolderItem musicFolderItem = (MusicFolderItem) item;
                if ("track".equals(musicFolderItem.getType())) {
                    Uri url = musicFolderItem.getUrl();
                    if (!url.equals(Uri.EMPTY)) {
                        downloadSong(((MusicFolderItem) item).getDownloadUrl(), musicFolderItem.getName(), url);
                    }
                } else if ("folder".equals(musicFolderItem.getType())) {
                    musicFolders(-1, musicFolderItem, musicFolderDownloadCallback);
                }
            } else if (item != null) {
                songs(songDownloadCallback, -1, SongViewDialog.SongsSortOrder.title.name(), null, item);
            }
        }
    }

    /**
     * Calculate and set player subscription states every time a client of the bus
     * un/registers.
     * <p>
     * For example, this ensures that if a new client subscribes and needs real
     * time updates, the player subscription states will be updated accordingly.
     */
    class EventBus extends de.greenrobot.event.EventBus {

        @Override
        public void register(Object subscriber) {
            super.register(subscriber);
            updateAllPlayerSubscriptionStates();
        }

        @Override
        public void register(Object subscriber, int priority) {
            super.register(subscriber, priority);
            updateAllPlayerSubscriptionStates();
        }

        @Override
        public void post(Object event) {
            Log.v("EventBus", "post() " + event.getClass().getSimpleName() + ": " + event);
            super.post(event);
        }

        @Override
        public void postSticky(Object event) {
            Log.v("EventBus", "postSticky() " + event.getClass().getSimpleName() + ": " + event);
            super.postSticky(event);
        }

        @Override
        public void registerSticky(Object subscriber) {
            super.registerSticky(subscriber);
            updateAllPlayerSubscriptionStates();
        }

        @Override
        public void registerSticky(Object subscriber, int priority) {
            super.registerSticky(subscriber, priority);
            updateAllPlayerSubscriptionStates();
        }

        @Override
        public synchronized void unregister(Object subscriber) {
            super.unregister(subscriber);
            updateAllPlayerSubscriptionStates();
        }
    }
}