From 5c7be50dfa3c78fefd3593a3339bec0934523d07 Mon Sep 17 00:00:00 2001 From: dkanada Date: Mon, 18 May 2020 21:23:17 +0900 Subject: [PATCH] use exoplayer as music backend --- app/build.gradle | 1 + .../gramophone/service/MultiPlayer.java | 269 ++++++++++-------- .../gramophone/service/MusicService.java | 54 ++-- .../gramophone/service/playback/Playback.java | 10 +- .../dkanada/gramophone/util/MusicUtil.java | 27 +- 5 files changed, 204 insertions(+), 157 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 2ce2e7b0..b781c918 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -42,6 +42,7 @@ android { dependencies { implementation 'com.github.jellyfin.jellyfin-apiclient-java:android:0.6.1' + implementation 'com.google.android.exoplayer:exoplayer:2.11.4' implementation 'androidx.core:core:1.2.0' implementation 'androidx.media:media:1.1.0' diff --git a/app/src/main/java/com/dkanada/gramophone/service/MultiPlayer.java b/app/src/main/java/com/dkanada/gramophone/service/MultiPlayer.java index 599e4160..add1d1b8 100644 --- a/app/src/main/java/com/dkanada/gramophone/service/MultiPlayer.java +++ b/app/src/main/java/com/dkanada/gramophone/service/MultiPlayer.java @@ -1,44 +1,163 @@ package com.dkanada.gramophone.service; import android.content.Context; -import android.media.MediaPlayer; -import android.os.PowerManager; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import android.net.Uri; + +import android.util.Log; import android.widget.Toast; import com.dkanada.gramophone.R; import com.dkanada.gramophone.service.playback.Playback; -public class MultiPlayer implements Playback, MediaPlayer.OnErrorListener, MediaPlayer.OnCompletionListener { - public static final String TAG = MultiPlayer.class.getSimpleName(); +import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.ExoPlayer; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.source.ConcatenatingMediaSource; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.ProgressiveMediaSource; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.source.hls.HlsMediaSource; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory; +import com.google.android.exoplayer2.util.Util; - private MediaPlayer mCurrentMediaPlayer = new MediaPlayer(); - private MediaPlayer mNextMediaPlayer; +import java.io.IOException; + +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; + +public class MultiPlayer implements Playback { + public static final String TAG = MultiPlayer.class.getSimpleName(); private Context context; - @Nullable + private OkHttpClient httpClient; + private SimpleExoPlayer exoPlayer; + + private ConcatenatingMediaSource mediaSource; private Playback.PlaybackCallbacks callbacks; - private boolean mIsInitialized = false; + private boolean isReady = false; + private boolean isPlaying = false; + private boolean isNew = false; + private boolean isFirst = true; + + private ExoPlayer.EventListener eventListener = new ExoPlayer.EventListener() { + @Override + public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { + Log.i(TAG,"onTracksChanged"); + } + + @Override + public void onLoadingChanged(boolean isLoading) { + Log.i(TAG,"onLoadingChanged: isLoading = " + isLoading); + } + + @Override + public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { + Log.i(TAG,"onPlayerStateChanged: playWhenReady = " + playWhenReady); + Log.i(TAG,"onPlayerStateChanged: playbackState = " + playbackState); + } + + @Override + public void onPositionDiscontinuity(int reason) { + Log.i(TAG,"onPositionDiscontinuity: reason = " + reason); + int windowIndex = exoPlayer.getCurrentWindowIndex(); + + if (windowIndex == 1) { + mediaSource.removeMediaSource(0); + if (mediaSource.getSize() != 0) { + // there are still songs left in the queue + callbacks.onTrackWentToNext(); + } else { + callbacks.onTrackEnded(); + } + } + } + + @Override + public void onPlayerError(ExoPlaybackException error) { + Log.i(TAG,"onPlaybackError: " + error.getMessage()); + if (context != null) { + Toast.makeText(context, context.getResources().getString(R.string.unplayable_file), Toast.LENGTH_SHORT).show(); + } + + stop(); + } + }; public MultiPlayer(final Context context) { this.context = context; - mCurrentMediaPlayer.setWakeMode(context, PowerManager.PARTIAL_WAKE_LOCK); + + httpClient = new OkHttpClient(); + exoPlayer = new SimpleExoPlayer.Builder(context).build(); + mediaSource = new ConcatenatingMediaSource(); } @Override - public boolean setDataSource(@NonNull final String path) { - return true; + public void setDataSource(@NonNull final String path) { + isReady = false; + if (context == null) { + return; + } + + isNew = true; + mediaSource = new ConcatenatingMediaSource(); + + exoPlayer.addListener(eventListener); + exoPlayer.prepare(mediaSource); + + appendDataSource(path, true); + isReady = true; } @Override public void setNextDataSource(@Nullable final String path) { + if (context == null) { + return; + } + + if (mediaSource.getSize() >= 2) { + mediaSource.removeMediaSource(1); + } + + appendDataSource(path, false); } - private boolean appendDataSource(@NonNull final String path) { - return true; + private void appendDataSource(String path, boolean next) { + Uri uri = Uri.parse(path); + + DataSource.Factory dataSource = new DefaultHttpDataSourceFactory(Util.getUserAgent(context, this.getClass().getName())); + httpClient.newCall(new Request.Builder().url(path).head().build()).enqueue(new Callback() { + @Override + public void onFailure(Call call, IOException e) { + Toast.makeText(context, context.getResources().getString(R.string.unplayable_file), Toast.LENGTH_SHORT).show(); + e.printStackTrace(); + } + + @Override + public void onResponse(Call call, Response response) { + MediaSource source; + if (response.header("Content-Type").equals("application/x-mpegURL")) { + source = new HlsMediaSource.Factory(dataSource).createMediaSource(uri); + } else { + source = new ProgressiveMediaSource.Factory(dataSource).createMediaSource(uri); + } + + mediaSource.addMediaSource(source); + if (!isFirst && next) { + start(); + } + + isFirst = false; + } + }); } @Override @@ -48,135 +167,61 @@ public class MultiPlayer implements Playback, MediaPlayer.OnErrorListener, Media @Override public boolean isInitialized() { - return mIsInitialized; + return isReady; } @Override public boolean start() { - try { - mCurrentMediaPlayer.start(); - return true; - } catch (IllegalStateException e) { - return false; + isPlaying = true; + exoPlayer.setPlayWhenReady(true); + + if (isNew) { + callbacks.onTrackStarted(); + isNew = false; } + + return true; } @Override public void stop() { - mCurrentMediaPlayer.reset(); - mIsInitialized = false; - } - - @Override - public void release() { - stop(); - - mCurrentMediaPlayer.release(); - if (mNextMediaPlayer != null) { - mNextMediaPlayer.release(); - } + exoPlayer.release(); + isReady = false; } @Override public boolean pause() { - try { - mCurrentMediaPlayer.pause(); - return true; - } catch (IllegalStateException e) { - return false; - } + isPlaying = false; + exoPlayer.setPlayWhenReady(false); + return true; } @Override public boolean isPlaying() { - return mIsInitialized && mCurrentMediaPlayer.isPlaying(); + return isReady && isPlaying; } @Override public int duration() { - if (!mIsInitialized) { - return -1; - } - - try { - return mCurrentMediaPlayer.getDuration(); - } catch (IllegalStateException e) { - return -1; - } + if (!isReady) return -1; + return (int) exoPlayer.getDuration(); } @Override public int position() { - if (!mIsInitialized) { - return -1; - } - - try { - return mCurrentMediaPlayer.getCurrentPosition(); - } catch (IllegalStateException e) { - return -1; - } + if (!isReady) return -1; + return (int) exoPlayer.getCurrentPosition(); } @Override - public int seek(final int whereto) { - try { - mCurrentMediaPlayer.seekTo(whereto); - return whereto; - } catch (IllegalStateException e) { - return -1; - } + public int seek(int position) { + exoPlayer.seekTo(position); + return position; } @Override - public boolean setVolume(final float vol) { - try { - mCurrentMediaPlayer.setVolume(vol, vol); - return true; - } catch (IllegalStateException e) { - return false; - } - } - - @Override - public boolean setAudioSessionId(final int sessionId) { - try { - mCurrentMediaPlayer.setAudioSessionId(sessionId); - return true; - } catch (@NonNull IllegalArgumentException | IllegalStateException e) { - return false; - } - } - - @Override - public int getAudioSessionId() { - return mCurrentMediaPlayer.getAudioSessionId(); - } - - @Override - public boolean onError(final MediaPlayer mp, final int what, final int extra) { - mIsInitialized = false; - mCurrentMediaPlayer.release(); - mCurrentMediaPlayer = new MediaPlayer(); - mCurrentMediaPlayer.setWakeMode(context, PowerManager.PARTIAL_WAKE_LOCK); - if (context != null) { - Toast.makeText(context, context.getResources().getString(R.string.unplayable_file), Toast.LENGTH_SHORT).show(); - } - - return false; - } - - @Override - public void onCompletion(final MediaPlayer mp) { - if (mp == mCurrentMediaPlayer && mNextMediaPlayer != null) { - mIsInitialized = false; - mCurrentMediaPlayer.release(); - mCurrentMediaPlayer = mNextMediaPlayer; - mIsInitialized = true; - mNextMediaPlayer = null; - if (callbacks != null) callbacks.onTrackWentToNext(); - } else { - if (callbacks != null) callbacks.onTrackEnded(); - } + public boolean setVolume(float volume) { + exoPlayer.setVolume(volume); + return true; } } diff --git a/app/src/main/java/com/dkanada/gramophone/service/MusicService.java b/app/src/main/java/com/dkanada/gramophone/service/MusicService.java index b754a2c6..e4c37faf 100644 --- a/app/src/main/java/com/dkanada/gramophone/service/MusicService.java +++ b/app/src/main/java/com/dkanada/gramophone/service/MusicService.java @@ -423,7 +423,6 @@ public class MusicService extends Service implements SharedPreferences.OnSharedP position = restoredPosition; openCurrent(); - prepareNext(); if (restoredPositionInTrack > 0) seek(restoredPositionInTrack); @@ -451,7 +450,7 @@ public class MusicService extends Service implements SharedPreferences.OnSharedP queueSaveHandler.removeCallbacksAndMessages(null); queueSaveHandlerThread.quitSafely(); - playback.release(); + playback.stop(); playback = null; mediaSession.release(); } @@ -468,24 +467,20 @@ public class MusicService extends Service implements SharedPreferences.OnSharedP playSongAt(getNextPosition(force)); } - private boolean openTrackAndPrepareNextAt(int position) { + private void openTrackAndPrepareNextAt(int position) { synchronized (this) { this.position = position; - boolean prepared = openCurrent(); - if (prepared) prepareNextImpl(); + + openCurrent(); + notifyChange(META_CHANGED); notHandledMetaChangedForCurrentTrack = false; - return prepared; } } - private boolean openCurrent() { + private void openCurrent() { synchronized (this) { - try { - return playback.setDataSource(getTrackUri(getCurrentSong())); - } catch (Exception e) { - return false; - } + playback.setDataSource(getTrackUri(getCurrentSong())); } } @@ -494,16 +489,10 @@ public class MusicService extends Service implements SharedPreferences.OnSharedP playerHandler.obtainMessage(PREPARE_NEXT).sendToTarget(); } - private boolean prepareNextImpl() { + private void prepareNextImpl() { synchronized (this) { - try { - int nextPosition = getNextPosition(false); - playback.setNextDataSource(getTrackUri(getSongAt(nextPosition))); - this.nextPosition = nextPosition; - return true; - } catch (Exception e) { - return false; - } + nextPosition = getNextPosition(false); + playback.setNextDataSource(getTrackUri(getSongAt(nextPosition))); } } @@ -805,11 +794,7 @@ public class MusicService extends Service implements SharedPreferences.OnSharedP } private void playSongAtImpl(int position) { - if (openTrackAndPrepareNextAt(position)) { - play(); - } else { - Toast.makeText(this, getResources().getString(R.string.unplayable_file), Toast.LENGTH_SHORT).show(); - } + openTrackAndPrepareNextAt(position); } public void pause() { @@ -1072,10 +1057,6 @@ public class MusicService extends Service implements SharedPreferences.OnSharedP } } - public int getAudioSessionId() { - return playback.getAudioSessionId(); - } - public MediaSessionCompat getMediaSession() { return mediaSession; } @@ -1093,13 +1074,6 @@ public class MusicService extends Service implements SharedPreferences.OnSharedP @Override public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { switch (key) { - case PreferenceUtil.GAPLESS_PLAYBACK: - if (sharedPreferences.getBoolean(key, false)) { - prepareNext(); - } else { - playback.setNextDataSource(null); - } - break; case PreferenceUtil.SHOW_ALBUM_COVER: case PreferenceUtil.BLUR_ALBUM_COVER: updateMediaSessionMetaData(); @@ -1114,6 +1088,12 @@ public class MusicService extends Service implements SharedPreferences.OnSharedP } } + @Override + public void onTrackStarted() { + handleAndSendChangeInternal(PLAY_STATE_CHANGED); + prepareNext(); + } + @Override public void onTrackWentToNext() { playerHandler.sendEmptyMessage(TRACK_WENT_TO_NEXT); diff --git a/app/src/main/java/com/dkanada/gramophone/service/playback/Playback.java b/app/src/main/java/com/dkanada/gramophone/service/playback/Playback.java index c540e5d6..3a486531 100644 --- a/app/src/main/java/com/dkanada/gramophone/service/playback/Playback.java +++ b/app/src/main/java/com/dkanada/gramophone/service/playback/Playback.java @@ -3,7 +3,7 @@ package com.dkanada.gramophone.service.playback; import androidx.annotation.Nullable; public interface Playback { - boolean setDataSource(String path); + void setDataSource(String path); void setNextDataSource(@Nullable String path); @@ -15,8 +15,6 @@ public interface Playback { void stop(); - void release(); - boolean pause(); boolean isPlaying(); @@ -29,11 +27,9 @@ public interface Playback { boolean setVolume(float vol); - boolean setAudioSessionId(int sessionId); - - int getAudioSessionId(); - interface PlaybackCallbacks { + void onTrackStarted(); + void onTrackWentToNext(); void onTrackEnded(); diff --git a/app/src/main/java/com/dkanada/gramophone/util/MusicUtil.java b/app/src/main/java/com/dkanada/gramophone/util/MusicUtil.java index aaa1495c..8a5a02f6 100644 --- a/app/src/main/java/com/dkanada/gramophone/util/MusicUtil.java +++ b/app/src/main/java/com/dkanada/gramophone/util/MusicUtil.java @@ -7,6 +7,7 @@ import android.net.Uri; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import android.text.TextUtils; +import android.util.Log; import android.widget.Toast; import com.dkanada.gramophone.App; @@ -16,6 +17,7 @@ import com.dkanada.gramophone.model.Artist; import com.dkanada.gramophone.model.Genre; import com.dkanada.gramophone.model.Song; +import org.jellyfin.apiclient.interaction.ApiClient; import org.jellyfin.apiclient.interaction.Response; import org.jellyfin.apiclient.model.dto.UserItemDataDto; @@ -24,7 +26,30 @@ import java.util.Locale; public class MusicUtil { public static Uri getSongFileUri(Song song) { - return Uri.parse(App.getApiClient().getApiUrl() + "/Audio/" + song.id + "/stream?static=true"); + if (song.id == null) return null; + + StringBuilder builder = new StringBuilder(); + ApiClient apiClient = App.getApiClient(); + + builder.append(apiClient.getApiUrl()); + builder.append("/Audio/"); + builder.append(song.id); + builder.append("/universal"); + builder.append("?UserId=" + apiClient.getCurrentUserId()); + builder.append("&DeviceId=" + apiClient.getDeviceId()); + + // web max is 12444445 and 320kbps is 320000 + builder.append("&MaxStreamingBitrate=10000000"); + builder.append("&Container=flac"); + builder.append("&TranscodingContainer=ts"); + builder.append("&TranscodingProtocol=hls"); + + // preferred codec when transcoding + builder.append("&AudioCodec=aac"); + builder.append("&api_key=" + apiClient.getAccessToken()); + + Log.i(MusicUtil.class.getName(), "playing audio: " + builder); + return Uri.parse(builder.toString()); } @NonNull