fm.krui.kruifm.StreamService.java Source code

Java tutorial

Introduction

Here is the source code for fm.krui.kruifm.StreamService.java

Source

/*
 * fm.krui.kruifm.StreamService - StreamService.java
 *
 * (C) 2013 - Tony Andrys
 * http://www.tonyandrys.com
 *
 * Created: 11/14/2013
 *
 * ---
 *
 * This file is part of KRUI.FM.
 *
 * KRUI.FM is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or(at your option) any later version.
 *
 * KRUI.FM is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with KRUI.FM.  If not, see <http://www.gnu.org/licenses/>.
 */

package fm.krui.kruifm;

import android.app.Notification;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Intent;
import android.content.SharedPreferences;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.media.AudioManager;
import android.media.MediaPlayer;
import android.os.IBinder;
import android.support.v4.content.LocalBroadcastManager;
import android.util.Log;

import java.io.IOException;
import java.util.Timer;
import java.util.TimerTask;

/**
 * fm.krui.kruifm - StreamService
 *
 * @author Tony Andrys
 *         Created: 08/25/2013
 *         (C) 2013 - Tony Andrys
 */

/**
 * Provides background streaming of KRUI audio streams as a foreground service.
 */
public class StreamService extends Service implements MediaPlayer.OnErrorListener, TrackUpdateListener {

    final private String TAG = StreamService.class.getName();
    public static boolean isRunning;

    final int NOTIFICATION_ID = 1492; // in 1492 Columbus sailed the ocean blue.
    final private int MAX_VOLUME = 100;
    final private int TRACK_UPDATE_INTERVAL = 30000; // Time to wait before checking for track updates in milliseconds.

    // Pref constants
    public static final String PREFS_NAME = "KRUI-CurrentTrack";
    public static final String PREFKEY_TRACK = "track";
    public static final String PREFKEY_ARTIST = "artist";
    public static final String PREFKEY_ALBUM = "album";

    // Intent broadcast constants
    public static final String BROADCAST_MESSAGE = "streamMessage";
    public static final String BROADCAST_KEY = "broadcastCommand";
    public static final String BROADCAST_COMMAND_PLAY = "playStream";
    public static final String BROADCAST_COMMAND_PAUSE = "pauseStream";
    public static final String BROADCAST_COMMAND_STOP = "stopStream";
    public static final String BROADCAST_COMMAND_UPDATE_PENDING = "updatePending";
    public static final String BROADCAST_COMMAND_UPDATE = "updateStream";
    public static final String BROADCAST_COMMAND_STATUS_MESSAGE = "statusMessage";
    public static final String BROADCAST_COMMAND_STATUS_HIDE = "hideStatus";
    public static final String STREAM_STATUS_KEY = "newStatus";
    public static final String STREAM_STATUS_DISPLAY_LENGTH = "isIndefinite";

    // Intent command constants
    public static final String INTENT_STREAM_URL = "streamUrl";
    public static final String ACTION_PLAY = "fm.krui.kruifm.PLAY";
    public static final String ACTION_PAUSE = "fm.krui.kruifm.PAUSE";
    public static final String ACTION_STOP = "fm.krui.kruifm.STOP";
    public static final String ACTION_CHANGE_URL = "fm.krui.kruifm.CHANGEURL";
    public static final String ACTION_STOP_UPDATES = "fm.krui.kruifm.STOPUPDATES";
    public static final String ACTION_START_UPDATES = "fm.krui.kruifm.STARTUPDATES";
    public static final String ACTION_MANUAL_TRACK_UPDATE = "fm.krui.kruifm.MANUALUPDATE";

    // Default stream to play is the 89.7 128kb/s stream
    private String streamUrl = "";

    // Timer members
    private Timer updateTimer;
    private TimerTask updateTimerTask;

    SharedPreferences prefs;
    private MediaPlayer mp;
    private Notification.Builder notificationBuilder;

    // Stream bools
    private boolean isPrepared;
    private boolean isPaused;

    public StreamService() {
    }

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

        // Initialize SharedPrefs file used to cache track info
        prefs = getApplicationContext().getSharedPreferences(PREFS_NAME, 0);

        // Set isRunning to true so we don't start multiple StreamServices from StreamFragment
        isRunning = true;

        // Set initial parameters and build audio player
        this.isPaused = false;
        this.isPrepared = false;
        buildAudioPlayer();

    }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {

        handleIntent(intent);

        // The audio streaming service should persist until it is explicitly stopped, so return sticky status.
        return START_STICKY;
    }

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

        // Set isRunning to false
        isRunning = false;

        stopForeground(true);

        // Broadcast closing intent to update UI
        // Stop and release media player resources
        Log.v(TAG, "Audio service has received signal to shutdown. Stopping audio and freeing resources...");
        mp.stop();
        mp.release();
        isPaused = false;
        isPrepared = false;
    }

    private void handleIntent(Intent intent) {

        // Get intent action
        String intentAction = "";
        try {
            intentAction = intent.getAction();
        } catch (NullPointerException e) {
            Log.e(TAG, "No intent action found! Can't do anything with this intent.");
            return;
        }

        // If play command is received, resume or prepare/play audio.
        if (intentAction.equals(ACTION_PLAY)) {
            if (isPaused && isPrepared) {
                resumeAudio();
            } else {
                Log.v(TAG, "ACTION_PLAY received, but player is not prepared. Preparing now...");
                streamUrl = intent.getStringExtra(INTENT_STREAM_URL);
                prepareAudio();
            }
        }

        // If pause command is received, pause the audio.
        else if (intentAction.equals(ACTION_PAUSE)) {
            pauseAudio();
        }

        else if (intentAction.equals(ACTION_STOP)) {
            stopSelf();
        }

        // If change url command is received, update stream url and flag player to
        // rebuild on next play.
        else if (intentAction.equals(ACTION_CHANGE_URL)) {
            String newUrl = intent.getStringExtra(INTENT_STREAM_URL);
            if (newUrl != null) {
                setStreamURL(newUrl);
                this.isPrepared = false;
                this.isPaused = false;
            } else {
                Log.e(TAG, "ERROR: Change URL requested, but no URL was passed in intent!");
            }
        }

        // Intent fired to perform a track update regardless of the state of the update timer
        else if (intentAction.equals(ACTION_MANUAL_TRACK_UPDATE)) {
            Log.v(TAG, "Manual track update requested. Performing update.");
            updateTrackInfo();
        }

        // If start updates command is received, Restart the update timer. This is received when the screen
        // is turned on after it has been locked by the user or timed out.
        else if (intentAction.equals(ACTION_START_UPDATES)) {
            setUpdateTimer(true);
        }

        // If stop updates command is received, stop the update timer. Don't waste bandwidth and resources
        // updating track info/album art that the user won't see.
        else if (intentAction.equals(ACTION_STOP_UPDATES)) {
            setUpdateTimer(false);
        }
    }

    /**
     * Pauses the MediaPlayer and stops update timer.
     */
    public void pauseAudio() {

        mp.pause();
        isPaused = true;

        // Rebuild notification with resume button
        String[] currentTrackInfo = getCurrentTrackInfo();
        updateNotification(currentTrackInfo[1], currentTrackInfo[0], false);

        // Send broadcast informing all interested components that streaming has paused
        broadcastMessage(BROADCAST_COMMAND_PAUSE);

        // Stop timer and clear track information to force a refresh on next play
        setUpdateTimer(false);
        setCurrentTrackInfo("", "", "");
        Log.i(TAG, "Stream paused.");

    }

    /**
     * Resumes a prepared audio feed and starts update timer.
     */
    public void resumeAudio() {

        // Start playing audio
        mp.start();
        isPaused = false;

        // Build base notification, retrieve current track info, and update the notification when it is available.
        startForegoundService();
        String[] currentTrackInfo = getCurrentTrackInfo();
        updateNotification(currentTrackInfo[1], currentTrackInfo[0], true);

        // Send broadcast informing all interested components that streaming has started
        broadcastMessage(BROADCAST_COMMAND_PLAY);

        // Start updating track information
        setUpdateTimer(true);
        Log.i(TAG, "Stream resumed.");

    }

    /**
     * Stops streaming audio and kills audio service.
     */
    public void stopAudio() {

        // Broadcast service closing intent
        broadcastMessage(BROADCAST_COMMAND_STOP);

        // Kill the service
        stopSelf();
    }

    /**
     * Sends a local broadcast with passed BROADCAST_COMMAND.
     * @param broadcastCommand BROADCAST_COMMAND String constant.
     */
    public void broadcastMessage(final String broadcastCommand) {
        Intent intent = new Intent(BROADCAST_MESSAGE);
        intent.putExtra(BROADCAST_KEY, broadcastCommand);
        LocalBroadcastManager.getInstance(this).sendBroadcast(intent);
        Log.v(TAG, "Sent local broadcast: " + intent.getStringExtra(BROADCAST_KEY));
    }

    /**
     * Updates and displays the stream status bar. Updates require a broadcast message of a
     * different format, which includes the status to display as well as its lifespan.
     * @param status String to be displayed in the status bar
     * @param displayUntilCancelled if this is true, the status message will be displayed until it is explicitly
     *                              cancelled by a BROADCAST_COMMAND_HIDE_STATUS message.
     */
    private void updateStreamStatus(String status, boolean displayUntilCancelled) {

        // Send a
        // TODO: If this method or broadcastMessage() get much bigger, it would be more correct to make a separate class dedicated to broadcasts.

        Intent intent = new Intent(BROADCAST_MESSAGE);
        intent.putExtra(BROADCAST_KEY, BROADCAST_COMMAND_STATUS_MESSAGE);
        intent.putExtra(STREAM_STATUS_KEY, status);
        intent.putExtra(STREAM_STATUS_DISPLAY_LENGTH, displayUntilCancelled);
        LocalBroadcastManager.getInstance(this).sendBroadcast(intent);
        Log.v(TAG, "Sent new status message: " + intent.getStringExtra(STREAM_STATUS_KEY));
        Log.v(TAG, "Indefinite status: " + intent.getBooleanExtra(STREAM_STATUS_DISPLAY_LENGTH, false));
    }

    public IBinder onBind(Intent intent) {
        return null;
    }

    /**
     * Builds and returns a configured, unprepared MediaPlayer and attach an error handler.
     */
    public MediaPlayer buildAudioPlayer() {

        // Build MediaPlayer
        mp = new MediaPlayer();

        try {
            mp.reset();
            mp.setAudioStreamType(AudioManager.STREAM_MUSIC);
            mp.setDataSource(streamUrl);
        } catch (IllegalArgumentException e) {
            Log.e(TAG, "Caught IllegalArgumentException: ");
            e.printStackTrace();
        } catch (IllegalStateException e) {
            Log.e(TAG, "Caught IllegalStateException: ");
            e.printStackTrace();
        } catch (SecurityException e) {
            Log.e(TAG, "Caught SecurityException: ");
            e.printStackTrace();
        } catch (IOException e) {
            Log.e(TAG, "Caught IOException: ");
            e.printStackTrace();
        }

        // Attach error handler to instance.
        /*mp.setOnErrorListener(new MediaPlayer.OnErrorListener() {
            
        @Override
        public boolean onError(MediaPlayer arg0, int arg1, int arg2) {
            
            // If there is an error in playback, stop and inform the user.
            mp = buildAudioPlayer();
            // FIXME: This should be a status bar message! Update this when that is fully implemented
            Toast.makeText(getApplicationContext(), "Failed to load the stream. Please check your internet connection and try again.", Toast.LENGTH_LONG).show();
            Log.e(TAG, "Error in playback. onError is being called.");
            
            return true;
        }
            
        });*/
        return mp;
    }

    /**
     * Changes stream source to passed URL.
     * @param newStreamUrl URL of the new target for player.
     */
    protected void reconfigureStream(String newStreamUrl) {
        streamUrl = newStreamUrl;

        // Stop stream if it is currently playing to prevent state exceptions
        if (mp.isPlaying()) {
            Log.v(TAG, "Stream source changed by user. Rebuilding stream.");
            mp.stop();
            Log.i(TAG, "Stream playback stopped.");
        }

    }

    /**
     * Prepares the player for streaming and plays the audio upon completion.
     */
    private void prepareAudio() {
        try {
            Log.v(TAG, "Attempting to play stream from " + streamUrl);
            mp.setOnPreparedListener(new MediaPlayer.OnPreparedListener() {

                @Override
                public void onPrepared(MediaPlayer mp) {

                    // When the stream is buffered, kill prompt and start playing automatically.
                    mp.start();
                    isPrepared = true;

                    // Clear cached track information and start audio service.
                    setCurrentTrackInfo("", "", "");
                    updateNotification("", "", true);
                    setUpdateTimer(true);

                    // Since we're not buffering anymore, hide the status bar from the user
                    broadcastMessage(BROADCAST_COMMAND_STATUS_HIDE);
                    Log.i(TAG, "Stream playback started.");

                }
            });

            // Prepares stream without blocking UI Thread
            mp.prepareAsync();
            updateStreamStatus(getString(R.string.stream_status_buffering), true);

        } catch (IllegalStateException e) {
            Log.e(TAG, "Caught IllegalStateException when preparing: ");
            e.printStackTrace();
        }
    }

    /**
     * Allows a component to change the URL played without restarting the service.
     * @param streamUrl
     */
    private void setStreamURL(String streamUrl) {
        this.streamUrl = streamUrl;

        // Stop MediaPlayer
        if (mp.isPlaying()) {
            mp.stop();
        }
        Log.v(TAG, "Stream source changed by user. Stream playback stopped.");

        // Rebuild player with new stream URL.
        mp.reset();
        mp = buildAudioPlayer();
        Log.v(TAG, "Media Player has been rebuilt with new source.");
    }

    private String getStreamURL() {
        return streamUrl;
    }

    /**
     * All MediaPlayer errors will call this method with details.
     * @param mp MediaPlayer object that threw the error
     * @param what int identifier of error type
     * @param extra int identifier of error code
     * @return true if error was handled by this method, false if not.
     */
    @Override
    public boolean onError(MediaPlayer mp, int what, int extra) {

        this.mp = buildAudioPlayer();

        // Log the error
        Log.e(TAG, "*** MediaPlayer has encountered a fatal error.");
        String errorType = "";
        String errorCode = "";
        if (what == MediaPlayer.MEDIA_ERROR_UNKNOWN) {
            errorType = "UNKNOWN ERROR";
        } else if (what == MediaPlayer.MEDIA_ERROR_SERVER_DIED) {
            errorType = "SERVER DIED";
        }
        Log.e(TAG, "*** Error Type: " + errorType);

        if (extra == MediaPlayer.MEDIA_ERROR_IO) {
            errorCode = "IO ERROR";
        } else if (extra == MediaPlayer.MEDIA_ERROR_MALFORMED) {
            errorCode = "MALFORMED MEDIA";
        } else if (extra == MediaPlayer.MEDIA_ERROR_UNSUPPORTED) {
            errorCode = "UNSUPPORTED MEDIA";
        } else if (extra == MediaPlayer.MEDIA_ERROR_TIMED_OUT) {
            errorCode = "MEDIA TIMED OUT";
        }
        Log.e(TAG, "*** Error Code: " + errorCode);
        // FIXME: UGLY. Clean this up.
        updateStreamStatus("Error! Type: " + errorType + " / Code: " + errorCode, false);
        isPrepared = false;
        return false;
    }

    /**
     * Launches a foreground service with minimal information -- should be updated using updateNotification as soon as
     * track information is available.
     */
    private void startForegoundService() {

        // Build the notification to be displayed during foreground execution
        notificationBuilder.setSmallIcon(R.drawable.play_icon_white);

        Notification notification = notificationBuilder.build();
        notification.flags |= Notification.FLAG_ONGOING_EVENT;
        startForeground(NOTIFICATION_ID, notification);
        Log.v(TAG, "Started foreground streaming service.");
    }

    /**
     * Updates the foreground notification icon with track information
     * @param artistName Name of artist to display
     * @param trackName Name of track to display
     * @param isPlaying true if audio is playing, false if not. This param modifies the notification actions appropriately.
     */
    private void updateNotification(String artistName, String trackName, boolean isPlaying) {
        PendingIntent actionPI;
        notificationBuilder = new Notification.Builder(getApplicationContext());

        // Set static notification elements (title, icon, etc)
        notificationBuilder.setContentTitle(getString(R.string.notification_title));
        notificationBuilder.setSmallIcon(R.drawable.ic_action_krui_logo_small);

        // PendingIntent is executed when user selects the notification.
        PendingIntent pi = PendingIntent.getActivity(getApplicationContext(), 0,
                new Intent(getApplicationContext(), StreamContainer.class), PendingIntent.FLAG_UPDATE_CURRENT);

        // If non-blank track information has been passed, add it to the notification.
        if ((!artistName.equals("")) && (!trackName.equals(""))) {
            notificationBuilder.setContentText(artistName + " - " + trackName);
        } else {
            notificationBuilder.setContentText(getString(R.string.notification_subtitle));
        }

        // Regardless of state, add a stop button to allow the user to stop the streaming service.
        Intent stopIntent = new Intent(this, StreamService.class);
        stopIntent.setAction(ACTION_STOP);
        PendingIntent stopPI = PendingIntent.getService(getApplicationContext(), 0, stopIntent,
                PendingIntent.FLAG_UPDATE_CURRENT);
        notificationBuilder.addAction(R.drawable.stop_button_white, getString(R.string.stop_audio), stopPI);

        // FOR FUN AND EXPERIMENTING, add album art as large icon if it has been stored
        // FIXME: This would work better if small album art was retrieved...
        Bitmap bitmap = retrieveAlbumArt();
        if (bitmap != null) {
            notificationBuilder.setLargeIcon(bitmap);
        }

        // Build the notification and launch foreground service.
        Notification notification = notificationBuilder.build();
        startForeground(NOTIFICATION_ID, notification);
        Log.v(TAG, "Notification built. Text: " + artistName + " " + trackName);
    }

    /**
     * Sets volume of the media player based on the value of the volume seek bar. Volume settings are logarithmic,
     * so conversion is required before setting volume.
     * @param soundVolume Current setting of seekBar. Valid parameters are in [0,100]
     */
    public void setVolume(int soundVolume) {
        if (isPrepared) {
            final float volume = (float) (1 - (Math.log(MAX_VOLUME - soundVolume) / Math.log(MAX_VOLUME)));
            mp.setVolume(volume, volume);
        }
    }

    /**
     * Enables and disables the track update timer.
     * @param startTimer true to start the timer, false to stop.
     */
    private void setUpdateTimer(boolean startTimer) {

        // Build timer
        updateTimer = new Timer();

        // If the timer is to be enabled, assign the TimerTask and schedule updates.
        if (startTimer) {
            updateTimerTask = new TimerTask() {
                @Override
                public void run() {
                    Log.v(TAG, "Timer task has been called! Updating track info...");
                    updateTrackInfo();
                }
            };
            updateTimer.scheduleAtFixedRate(updateTimerTask, 0, TRACK_UPDATE_INTERVAL);
            Log.v(TAG, "Timer started!");
            Log.v(TAG, "Update schedule is set at " + TRACK_UPDATE_INTERVAL + " milliseconds.");
        }

        // If the timer is to be disabled, cancel and purge the timer object.
        else {
            if (updateTimerTask != null && updateTimer != null) {
                updateTimerTask.cancel();
                updateTimer.cancel();
                Log.v(TAG, "Timer stopped and task has been purged.");
            }
        }
    }

    /**
     * Returns the stored currently playing track information as a String array
     * @return String[] of information in the format {Track Name, Artist Name, Album Name}
     */
    private String[] getCurrentTrackInfo() {
        // {songName, artistName, albumName}
        String[] currentTrackInfo = { prefs.getString(PREFKEY_TRACK, ""), prefs.getString(PREFKEY_ARTIST, ""),
                prefs.getString(PREFKEY_ALBUM, "") };
        Log.v(TAG, "Track information requested!");
        Log.v(TAG, "Stored track name: " + prefs.getString(PREFKEY_TRACK, ""));
        Log.v(TAG, "Stored artist: " + prefs.getString(PREFKEY_ARTIST, ""));
        Log.v(TAG, "Stored album: " + prefs.getString(PREFKEY_ALBUM, ""));
        return currentTrackInfo;
    }

    private void setCurrentTrackInfo(String trackName, String artistName, String albumName) {
        SharedPreferences.Editor editor = prefs.edit();
        editor.putString(PREFKEY_TRACK, trackName);
        editor.putString(PREFKEY_ARTIST, artistName);
        editor.putString(PREFKEY_ALBUM, albumName);
        editor.commit();
        FavoriteTrackManager.setFavoriteFlag(this, false);
        Log.v(TAG, "Stored current track info into SharedPreferences. Reset favorite flag.");
    }

    private void updateTrackInfo() {

        // Store current track info as an array
        String[] currentTrackInfo = getCurrentTrackInfo();

        // Start track update task
        new TrackUpdateHandler(this, this, currentTrackInfo,
                new PreferenceManager(this).getAlbumArtDownloadPreference()).execute();
    }

    @Override
    public void onTrackUpdate() {

        // Get current track info
        String[] currentTrackInfo = getCurrentTrackInfo();
        String trackName = currentTrackInfo[0];
        String artistName = currentTrackInfo[1];
        String albumName = currentTrackInfo[2];

        // Update the foreground notification
        updateNotification(artistName, trackName, true);

        // Send a broadcast to trigger UI updates from player
        broadcastMessage(BROADCAST_COMMAND_UPDATE);
    }

    /**
     * Retrieves album art of currently playing song from cache.
     * @return Album art as a bitmap or null if it could not be retrieved.
     */
    private Bitmap retrieveAlbumArt() {
        Bitmap bitmap = BitmapFactory
                .decodeFile(getApplicationContext().getFilesDir() + "/" + TrackUpdateHandler.ALBUM_ART_FILENAME);
        if (bitmap == null) {
            Log.w(TAG, "Album art could not be retrieved from storage, returning null...");
            return null;
        } else {
            return bitmap;
        }
    }
}