poniedziałek, 4 kwietnia 2016

[DSP2016] Android prosto z poligonu odc.6 (odtwarzanie audio - podejście drugie)

Jak obiecywałem w tym odcinku podzielę się swoimi odczuciami związanymi z bardziej profesjonalną obsługą odtwarzania audio w tle.

Screenshot_20160403-094053  Screenshot_20160403-094029[3]

Generalnie nie ma lekko mimo wspomnianych wcześniej sampli z SDK, które zawierają w sobie masę kodu i funkjonalności. MediaBrowserService powiedziałbym jest taką prostszą i mniej funkcjonalną wersją Universal Android Music Player. Ten drugi jest z kolei zaimplementowany przy użyciu compat library, przez co może podziałać także na starszych wersjach Androida.

Po rozczajeniu sampli zaadoptowałem część oferowanych przez nich rozwiązań u siebie. Mogę przegladać pliki audio za pomocą serwisu, który potrafi je kolejkować i odtwarzać bez żadnych problemów w tle (także przy uśpieniu urządzenia czy z obsługą audio focusa itd.).  UI na dole wszystkich dwóch moich aktywności jest na bieżąco synchronizowane ze stanem serwisu. Dodatkowo nad serwisem panujemy też za pomocą interaktywnej notyfikacji. Nie ma jednak róży bez kolców. Wiązało się to z nową implementacją pobierania plików do przeglądania. Obecna wersja master w gicie  ma też kilka funkcjonalnych ograniczeń w porównaniu z poprzednią zostawioną na branchu (nie działa jeszcze wyszukiwanie plików przez SearchView oraz na obecny moment w Android 6.x trzeba samemu w systemowych Ustawieniach aplikacji nadać uprawnienie dostępu do external storage, zwanego po polsku pamięcią, jeśli tego nie zrobimy otrzymamy przy otwieraniu app-ki roboczą notyfikację o braku uprawnienia).

Przejdźmy teraz do omówienia zasadniczych filarów obecnego rozwiązania.

model/MusicProvider.java  -  klasa odpowiedzialna za dostarczanie danych, używana przez serwis lub jego komponenty. Ładuję pliki audio z external storage oraz pozwalam je wyszukiwać w ramach płaskiej hierarchii. Na razie nie wspieram jak w samplach wyszukiwania po gatunku muzyki czy artystach, jak kiedyś rozbuduję UI to może dodam taką funkcjonalność. Mamy tutaj AsyncTask, by nie blokował się interfejs użytkownika (serwis działa domyślnie w tym samym wątku co aktywności). Najważniejsze metody:

public Iterable<MediaMetadataCompat> searchMusic(String query) {
if (mCurrentState != State.INITIALIZED) {
return Collections.emptyList();
}

ArrayList<MediaMetadataCompat> result = new ArrayList<>();
query = query.toLowerCase();
for (MutableMediaMetadata track : mMusicListById.values()) {
if (track.metadata.getString(MediaMetadataCompat.METADATA_KEY_TITLE).toLowerCase().contains(query) ||
track.metadata.getString(MediaMetadataCompat.METADATA_KEY_ARTIST).toLowerCase().contains(query)) {
result.add(track.metadata);
}
}
return result;
}
public MediaMetadataCompat getMusic(String musicId) {
return mMusicListById.containsKey(musicId) ? mMusicListById.get(musicId).metadata : null;
}
public void retrieveMediaAsync(Context context, final Callback callback) {
if (mCurrentState == State.INITIALIZED) {
if (callback != null) {
// Nothing to do, execute callback immediately
callback.onMusicCatalogReady(true);
}
return;
}
    new AsyncTask<Context, Void, State>() {
@Override
protected State doInBackground(Context... params) {
retrieveMedia(params[0]);
return mCurrentState;
}

@Override
protected void onPostExecute(State current) {
if (callback != null) {
callback.onMusicCatalogReady(current == State.INITIALIZED);
}
}
}.execute(context);
}

private synchronized void retrieveMedia(Context context) {

try {
if (mCurrentState == State.NON_INITIALIZED) {
mCurrentState = State.INITIALIZING;

String[] projection = new String[] {
MediaStore.Audio.Media._ID,
MediaStore.Audio.Media.ARTIST,
MediaStore.Audio.Media.TITLE,
MediaStore.Audio.Media.DURATION,
MediaStore.Audio.Media.DATA,
MediaStore.Audio.Media.MIME_TYPE
};

String selection = MediaStore.Audio.Media.IS_MUSIC + "!= 0";
String sortOrder = MediaStore.Audio.Media.DATE_ADDED + " DESC";

Cursor cursor = context.getContentResolver().query(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,projection,selection,null,sortOrder);

if (cursor != null && cursor.moveToFirst()) {
do {
int idColumn = cursor.getColumnIndex(MediaStore.Audio.Media._ID);
int artistColumn = cursor.getColumnIndex(MediaStore.Audio.Media.ARTIST);
int titleColumn = cursor.getColumnIndex(MediaStore.Audio.Media.TITLE);
int durationColumn = cursor.getColumnIndex(MediaStore.Audio.Media.DURATION);
int filePathIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATA);

String id = Long.toString(cursor.getLong(idColumn));

MediaMetadataCompat item = new MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
.putString(CUSTOM_METADATA_TRACK_SOURCE, cursor.getString(filePathIndex))
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, cursor.getString(artistColumn))
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, cursor.getString(titleColumn))
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, cursor.getInt(durationColumn))
.build();

mMusicListById.put(id, new MutableMediaMetadata(id, item));

} while (cursor.moveToNext());
}

mCurrentState = State.INITIALIZED;
}
} finally {
if (mCurrentState != State.INITIALIZED) {
mCurrentState = State.NON_INITIALIZED;
}
}
}

public List<MediaBrowserCompat.MediaItem> getChildren(String mediaId, Resources resources) {
List<MediaBrowserCompat.MediaItem> mediaItems = new ArrayList<>();

//zawsze lista (na razie bez kategorii)

for (MutableMediaMetadata m : mMusicListById.values()) {
mediaItems.add(createMediaItem(m.metadata));
}

return mediaItems;
}
private MediaBrowserCompat.MediaItem createMediaItem(MediaMetadataCompat metadata) {
String id = metadata.getDescription().getMediaId();
Bundle playExtras = new Bundle();
playExtras.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, metadata.getLong(MediaMetadataCompat.METADATA_KEY_DURATION));

MediaDescriptionCompat desc =
new MediaDescriptionCompat.Builder()
.setMediaId(id)
.setTitle(metadata.getString(MediaMetadataCompat.METADATA_KEY_TITLE))
.setSubtitle(metadata.getString(MediaMetadataCompat.METADATA_KEY_ARTIST))
.setExtras(playExtras)
.build();

return new MediaBrowserCompat.MediaItem(desc,
MediaBrowserCompat.MediaItem.FLAG_PLAYABLE);
}

Jak widzimy provider operuje zasadniczo na dwóch klasach z SDK: MediaBrowserCompat.MediaItem (element listy z częścią danych) oraz MediaMetadataCompat (pełne metadane o pozycji audio). Metoda retrieveMediaAsync jest wywoływana podczas tworzenia serwisu (może to kiedyś zmienimy), a getChildren przez metodę serwisu o podobnej nazwie, którą z kolei wywołuje infrastruktura Android. Inne metody też są wywoływane przez serwis lub jego komponenty. Należą do nich:

playback/LocalPlayback.java  - odtwarzanie audio za pomocą MediaPlayer (obsługa komend, audio focusa, itd.)

playback/QueueManager.java - zarządzanie kolejkami odtwarzania

playback/PlaybackManager.java - spaja działanie serwisu, playbacku i menadżera kolejek

MediaNotificationManager.java - odpowiada za interaktywną notyfikację
Sam serwis, o którym dużo wspominamy, znajduje się w klasie MusicService.java. Musi dziedzić po klasie MediaBrowserServiceCompat (oczywiście jak ktoś nie używa compat library to MediaBrowserService). Widzimy tutaj metody onCreate i onStartCommand, które implementuje się z reguły w każdym serwisie i kilka metod typowych dla MediaBrowserService. Są to onGetRoot i onLoadChildren, które są wywoływane po podłączeniu się części UI do sesji serwisu. Serwis jest też listenerem zdarzeń zachodzących w PlaybackManager (poprzez implementację interfejsu PlaybackManager.PlaybackServiceCallback, tak to Java, nie C#).   W metodzie onCreate sprawdzam, czy mam uprawnienie do external storage (aby serwis nie wyrzucił wyjątku na dzień dobry przy rozpoczęciu korzystania z aplikacji tuż po jej instalacji).  Jak nie ma uprawnienia, to informuję usera notyfikacją.
Context context = getApplicationContext();
int hasReadExternalStoragePermission = ActivityCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE);

if (hasReadExternalStoragePermission == PackageManager.PERMISSION_GRANTED){

mMusicProvider.retrieveMediaAsync(context, null);

} else {

Toast.makeText(context, "READ_EXTERNAL_STORAGE Denied", Toast.LENGTH_SHORT).show();
}

Przejdźmy teraz do interfejsu użytkownika. Zasadniczo przydadzą się nam tutaj:

ui/BaseActivity.java - klasa bazowa, dla wszystkich aktywności które chcą się synchronizować z serwisem. Używa klas z SDK takich jak MediaBrowserCompat (do pobierania i przeglądania pozycji audio) i MediaControllerCompat (zdalne sterowanie odtwarzaniem w serwisie). U mnie dziedziczy wprost z AppCompatActivity (nie korzystam póki co na razie jak sample z drawer-a czyli wysuwanego panelu bocznego po naciśnięciu przycisku “hamburgera”)

ui/PlaybackControlsFragment.java (+ res/layout/fragment_playback_controls.xaml) - fragment odpowiedzialny za prezentowanie aktualnego stanu odtwarzania, a także na play/pause na dole aktywności

Co trzeba zrobić by mieć dolny pasek “odtwarzacza” na swojej aktywności?  U mnie takie coś pokazuję zarówno w MainActivity jak i FileListActivity. Nie trzeba już wiele. MainActivity, które pokazuje tylko ten pasek (bez przeglądania pozycji audio) dziedziczy tylko po BaseActivity oraz dodaje PlaybackControlsFragment do swojego layoutu. Zawartość content_main.xml prezentuje się na bieżący moment następująco:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
tools:context="com.apps.kruszyn.lightorganapp.MainActivity"
tools:showIn="@layout/activity_main">
<android.support.v7.widget.CardView
android:id="@+id/controls_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
app:cardElevation="8dp">

<fragment android:name="com.apps.kruszyn.lightorganapp.ui.PlaybackControlsFragment"
android:id="@+id/fragment_playback_controls"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
tools:layout="@layout/fragment_playback_controls" />

</android.support.v7.widget.CardView>

</RelativeLayout>

Nieco więcej pracy jest przy FileListActivity (prace nie są jeszcze ukończone z uwagi na brak obsługi SearchView). Póki co zmodyfikowałem jej layout:

<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
tools:context="com.apps.kruszyn.lightorganapp.FileListActivity">

<android.support.design.widget.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:theme="@style/AppTheme.AppBarOverlay">

<android.support.v7.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorPrimary"
app:popupTheme="@style/AppTheme.PopupOverlay" />

</android.support.design.widget.AppBarLayout>

<FrameLayout
android:id="@+id/frameLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">

<android.support.v7.widget.CardView
android:id="@+id/controls_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
app:cardElevation="8dp">

<fragment android:name="com.apps.kruszyn.lightorganapp.ui.PlaybackControlsFragment"
android:id="@+id/fragment_playback_controls"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
tools:layout="@layout/fragment_playback_controls" />

</android.support.v7.widget.CardView>

<android.support.v7.widget.RecyclerView
android:id="@+id/item_list"
android:name="com.apps.kruszyn.myapplication.ItemListFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="8dp"
android:layout_marginLeft="6dp"
android:layout_marginRight="6dp"
tools:listitem="@layout/file_list_item" />

</FrameLayout>


</android.support.design.widget.CoordinatorLayout>
oraz obsłużyłem ładowanie pozycji audio za pośrednictwem MediaBrowserCompat i odtwarzanie ich po naciśnięciu za pośrednictwem MediaControllerCompat:
  private final MediaBrowserCompat.SubscriptionCallback mSubscriptionCallback =
new MediaBrowserCompat.SubscriptionCallback() {
@Override
public void onChildrenLoaded(@NonNull String parentId,
@NonNull List<MediaBrowserCompat.MediaItem> children) {
try {
mAdapter.setFilter(children);
mAdapter.notifyDataSetChanged();
} catch (Throwable t) {
}
}

@Override
public void onError(@NonNull String id) {
}
};
    @Override
public void onStart() {
super.onStart();

MediaBrowserCompat mediaBrowser = getMediaBrowser();
if (mediaBrowser.isConnected()) {
onConnected();
}
}

@Override
public void onStop() {
super.onStop();
MediaBrowserCompat mediaBrowser = getMediaBrowser();
if (mediaBrowser != null && mediaBrowser.isConnected() && mMediaId != null) {
mediaBrowser.unsubscribe(mMediaId);
}

}
public void onConnected() {

mMediaId = getMediaBrowser().getRoot();
    getMediaBrowser().unsubscribe(mMediaId);

getMediaBrowser().subscribe(mMediaId, mSubscriptionCallback);
    MediaControllerCompat controller = getSupportMediaController();
if (controller != null) {
controller.registerCallback(mMediaControllerCallback);
}
}
@Override
protected void onMediaControllerConnected() {
onConnected();
}


public void onMediaItemSelected(MediaBrowserCompat.MediaItem item) {

if (item.isPlayable()) {
getSupportMediaController().getTransportControls()
.playFromMediaId(item.getMediaId(), null);

Intent intent = new Intent(this, MainActivity.class);
startActivity(intent);
}
}
Ta ostatnia metoda, jak łatwo się domyślić jest wywoływana po naciśnięciu pozycji na liście.
Oczywiście na koniec trzeba dodać, że także manifest aplikacji należało odpowiednio zmodyfikować, by porejestrowały się odpowiednie uprawnienia, serwisy, usługi. Kierowałem się raczej minimalistycznym podejściem, w aktualnej wersji nie czułem wielkiej potrzeby mocnej obsługi Android TV, Wear, Auto (niewykluczone że kiedyś powrócę do tej tematyki).
P.S Aktualnie app-ka tworzy sama kolejkę ze wszystkich plików, tak więc po zakończeniu jednego kawałka puszczany jest następny  i tak w kółko, tak więc dzięki DSP Challenge mam możliwość długiego słuchania muzyki –Winking smile

Brak komentarzy: