Merge branch 'develop' into video-cleanup
This commit is contained in:
commit
2655a4a606
|
@ -10,7 +10,7 @@ ext.versions = [
|
|||
androidxcore : "1.5.0",
|
||||
ktlint : "0.37.1",
|
||||
ktlintGradle : "9.2.1",
|
||||
detekt : "1.17.1",
|
||||
detekt : "1.18.0",
|
||||
jacoco : "0.8.7",
|
||||
preferences : "1.1.1",
|
||||
media : "1.3.1",
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="org.moire.ultrasonic"
|
||||
android:installLocation="auto">
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="org.moire.ultrasonic"
|
||||
android:installLocation="auto">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
|
@ -27,6 +28,14 @@
|
|||
android:name=".app.UApp"
|
||||
android:label="@string/common.appname"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<meta-data android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc"/>
|
||||
|
||||
<!--Used by Android Auto-->
|
||||
<meta-data android:name="com.google.android.gms.car.notification.SmallIcon"
|
||||
android:resource="@mipmap/ic_launcher" />
|
||||
|
||||
<activity android:name=".activity.NavigationActivity"
|
||||
android:configChanges="orientation|keyboardHidden"
|
||||
android:label="@string/common.appname"
|
||||
|
@ -51,6 +60,17 @@
|
|||
android:exported="false">
|
||||
</service>
|
||||
|
||||
<service
|
||||
tools:ignore="ExportedService"
|
||||
android:name=".service.AutoMediaBrowserService"
|
||||
android:label="@string/common.appname"
|
||||
android:exported="true">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.media.browse.MediaBrowserService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<receiver android:name=".receiver.MediaButtonIntentReceiver">
|
||||
<intent-filter android:priority="2147483647">
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON"/>
|
||||
|
|
|
@ -33,6 +33,7 @@ import org.moire.ultrasonic.service.Consumer;
|
|||
import org.moire.ultrasonic.service.MediaPlayerController;
|
||||
import org.moire.ultrasonic.util.Constants;
|
||||
import org.moire.ultrasonic.util.FileUtil;
|
||||
import org.moire.ultrasonic.util.MediaSessionHandler;
|
||||
import org.moire.ultrasonic.util.PermissionUtil;
|
||||
import org.moire.ultrasonic.util.ThemeChangedEventDistributor;
|
||||
import org.moire.ultrasonic.util.TimeSpanPreference;
|
||||
|
@ -89,6 +90,7 @@ public class SettingsFragment extends PreferenceFragmentCompat
|
|||
private final Lazy<MediaPlayerController> mediaPlayerControllerLazy = inject(MediaPlayerController.class);
|
||||
private final Lazy<PermissionUtil> permissionUtil = inject(PermissionUtil.class);
|
||||
private final Lazy<ThemeChangedEventDistributor> themeChangedEventDistributor = inject(ThemeChangedEventDistributor.class);
|
||||
private final Lazy<MediaSessionHandler> mediaSessionHandler = inject(MediaSessionHandler.class);
|
||||
|
||||
@Override
|
||||
public void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
|
@ -468,7 +470,7 @@ public class SettingsFragment extends PreferenceFragmentCompat
|
|||
|
||||
private void setMediaButtonsEnabled(boolean enabled) {
|
||||
lockScreenEnabled.setEnabled(enabled);
|
||||
Util.updateMediaButtonEventReceiver();
|
||||
mediaSessionHandler.getValue().updateMediaButtonReceiver();
|
||||
}
|
||||
|
||||
private void setBluetoothPreferences(boolean enabled) {
|
||||
|
|
|
@ -1,108 +0,0 @@
|
|||
package org.moire.ultrasonic.service;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.AsyncTask;
|
||||
import timber.log.Timber;
|
||||
|
||||
import org.moire.ultrasonic.util.Constants;
|
||||
import org.moire.ultrasonic.util.FileUtil;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.locks.Lock;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
/**
|
||||
* This class is responsible for the serialization / deserialization
|
||||
* of the DownloadQueue (playlist) to the filesystem.
|
||||
* It also serializes the player state e.g. current playing number and play position.
|
||||
*/
|
||||
public class DownloadQueueSerializer
|
||||
{
|
||||
public final Lock lock = new ReentrantLock();
|
||||
public final AtomicBoolean setup = new AtomicBoolean(false);
|
||||
private Context context;
|
||||
|
||||
public DownloadQueueSerializer(Context context)
|
||||
{
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
public void serializeDownloadQueue(Iterable<DownloadFile> songs, int currentPlayingIndex, int currentPlayingPosition)
|
||||
{
|
||||
if (!setup.get())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
new SerializeTask().execute(songs, currentPlayingIndex, currentPlayingPosition);
|
||||
}
|
||||
|
||||
public void serializeDownloadQueueNow(Iterable<DownloadFile> songs, int currentPlayingIndex, int currentPlayingPosition)
|
||||
{
|
||||
State state = new State();
|
||||
for (DownloadFile downloadFile : songs)
|
||||
{
|
||||
state.songs.add(downloadFile.getSong());
|
||||
}
|
||||
state.currentPlayingIndex = currentPlayingIndex;
|
||||
state.currentPlayingPosition = currentPlayingPosition;
|
||||
|
||||
Timber.i("Serialized currentPlayingIndex: %d, currentPlayingPosition: %d", state.currentPlayingIndex, state.currentPlayingPosition);
|
||||
FileUtil.serialize(context, state, Constants.FILENAME_DOWNLOADS_SER);
|
||||
}
|
||||
|
||||
public void deserializeDownloadQueue(Consumer<State> afterDeserialized)
|
||||
{
|
||||
new DeserializeTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, afterDeserialized);
|
||||
}
|
||||
|
||||
public void deserializeDownloadQueueNow(Consumer<State> afterDeserialized)
|
||||
{
|
||||
State state = FileUtil.deserialize(context, Constants.FILENAME_DOWNLOADS_SER);
|
||||
if (state == null) return;
|
||||
Timber.i("Deserialized currentPlayingIndex: " + state.currentPlayingIndex + ", currentPlayingPosition: " + state.currentPlayingPosition);
|
||||
afterDeserialized.accept(state);
|
||||
}
|
||||
|
||||
private class SerializeTask extends AsyncTask<Object, Void, Void>
|
||||
{
|
||||
@Override
|
||||
protected Void doInBackground(Object... params)
|
||||
{
|
||||
if (lock.tryLock())
|
||||
{
|
||||
try
|
||||
{
|
||||
Thread.currentThread().setName("SerializeTask");
|
||||
serializeDownloadQueueNow((Iterable<DownloadFile>)params[0], (int)params[1], (int)params[2]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private class DeserializeTask extends AsyncTask<Object, Void, Void>
|
||||
{
|
||||
@Override
|
||||
protected Void doInBackground(Object... params)
|
||||
{
|
||||
try
|
||||
{
|
||||
Thread.currentThread().setName("DeserializeTask");
|
||||
lock.lock();
|
||||
deserializeDownloadQueueNow((Consumer<State>)params[0]);
|
||||
setup.set(true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock.unlock();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,308 +0,0 @@
|
|||
/*
|
||||
This file is part of Subsonic.
|
||||
|
||||
Subsonic 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.
|
||||
|
||||
Subsonic 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 Subsonic. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Copyright 2009 (C) Sindre Mehus
|
||||
*/
|
||||
package org.moire.ultrasonic.service;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.content.SharedPreferences;
|
||||
import android.media.AudioManager;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import timber.log.Timber;
|
||||
import android.view.KeyEvent;
|
||||
|
||||
import org.moire.ultrasonic.R;
|
||||
import org.moire.ultrasonic.app.UApp;
|
||||
import org.moire.ultrasonic.domain.PlayerState;
|
||||
import org.moire.ultrasonic.util.CacheCleaner;
|
||||
import org.moire.ultrasonic.util.Constants;
|
||||
import org.moire.ultrasonic.util.Util;
|
||||
|
||||
/**
|
||||
* This class is responsible for handling received events for the Media Player implementation
|
||||
*
|
||||
* @author Sindre Mehus
|
||||
*/
|
||||
public class MediaPlayerLifecycleSupport
|
||||
{
|
||||
private boolean created = false;
|
||||
private final DownloadQueueSerializer downloadQueueSerializer; // From DI
|
||||
private final MediaPlayerController mediaPlayerController; // From DI
|
||||
private final Downloader downloader; // From DI
|
||||
|
||||
private BroadcastReceiver headsetEventReceiver;
|
||||
|
||||
public MediaPlayerLifecycleSupport(DownloadQueueSerializer downloadQueueSerializer,
|
||||
final MediaPlayerController mediaPlayerController, final Downloader downloader)
|
||||
{
|
||||
this.downloadQueueSerializer = downloadQueueSerializer;
|
||||
this.mediaPlayerController = mediaPlayerController;
|
||||
this.downloader = downloader;
|
||||
|
||||
Timber.i("LifecycleSupport constructed");
|
||||
}
|
||||
|
||||
public void onCreate()
|
||||
{
|
||||
onCreate(false, null);
|
||||
}
|
||||
|
||||
private void onCreate(final boolean autoPlay, final Runnable afterCreated)
|
||||
{
|
||||
if (created)
|
||||
{
|
||||
if (afterCreated != null) afterCreated.run();
|
||||
return;
|
||||
}
|
||||
|
||||
registerHeadsetReceiver();
|
||||
|
||||
mediaPlayerController.onCreate();
|
||||
if (autoPlay) mediaPlayerController.preload();
|
||||
|
||||
this.downloadQueueSerializer.deserializeDownloadQueue(new Consumer<State>() {
|
||||
@Override
|
||||
public void accept(State state) {
|
||||
mediaPlayerController.restore(state.songs, state.currentPlayingIndex, state.currentPlayingPosition, autoPlay, false);
|
||||
|
||||
// Work-around: Serialize again, as the restore() method creates a serialization without current playing info.
|
||||
MediaPlayerLifecycleSupport.this.downloadQueueSerializer.serializeDownloadQueue(downloader.downloadList,
|
||||
downloader.getCurrentPlayingIndex(), mediaPlayerController.getPlayerPosition());
|
||||
|
||||
if (afterCreated != null) afterCreated.run();
|
||||
}
|
||||
});
|
||||
|
||||
new CacheCleaner().clean();
|
||||
created = true;
|
||||
Timber.i("LifecycleSupport created");
|
||||
}
|
||||
|
||||
public void onDestroy()
|
||||
{
|
||||
if (!created) return;
|
||||
downloadQueueSerializer.serializeDownloadQueueNow(downloader.downloadList,
|
||||
downloader.getCurrentPlayingIndex(), mediaPlayerController.getPlayerPosition());
|
||||
mediaPlayerController.clear(false);
|
||||
UApp.Companion.applicationContext().unregisterReceiver(headsetEventReceiver);
|
||||
mediaPlayerController.onDestroy();
|
||||
created = false;
|
||||
Timber.i("LifecycleSupport destroyed");
|
||||
}
|
||||
|
||||
public void receiveIntent(Intent intent)
|
||||
{
|
||||
if (intent == null) return;
|
||||
String intentAction = intent.getAction();
|
||||
if (intentAction == null || intentAction.isEmpty()) return;
|
||||
|
||||
Timber.i("Received intent: %s", intentAction);
|
||||
|
||||
if (intentAction.equals(Constants.CMD_PROCESS_KEYCODE)) {
|
||||
if (intent.getExtras() != null) {
|
||||
KeyEvent event = (KeyEvent) intent.getExtras().get(Intent.EXTRA_KEY_EVENT);
|
||||
if (event != null) {
|
||||
handleKeyEvent(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
handleUltrasonicIntent(intentAction);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Headset Intent Receiver is responsible for resuming playback when a headset is inserted
|
||||
* and pausing it when it is removed.
|
||||
* Unfortunately this Intent can't be registered in the AndroidManifest, so it works only
|
||||
* while Ultrasonic is running.
|
||||
*/
|
||||
private void registerHeadsetReceiver() {
|
||||
final SharedPreferences sp = Util.getPreferences();
|
||||
final Context context = UApp.Companion.applicationContext();
|
||||
final String spKey = context
|
||||
.getString(R.string.settings_playback_resume_play_on_headphones_plug);
|
||||
|
||||
headsetEventReceiver = new BroadcastReceiver() {
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
final Bundle extras = intent.getExtras();
|
||||
|
||||
if (extras == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Timber.i("Headset event for: %s", extras.get("name"));
|
||||
final int state = extras.getInt("state");
|
||||
if (state == 0) {
|
||||
if (!mediaPlayerController.isJukeboxEnabled()) {
|
||||
mediaPlayerController.pause();
|
||||
}
|
||||
} else if (state == 1) {
|
||||
if (!mediaPlayerController.isJukeboxEnabled() &&
|
||||
sp.getBoolean(spKey, false) &&
|
||||
mediaPlayerController.getPlayerState() == PlayerState.PAUSED) {
|
||||
mediaPlayerController.start();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
IntentFilter headsetIntentFilter;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
|
||||
{
|
||||
headsetIntentFilter = new IntentFilter(AudioManager.ACTION_HEADSET_PLUG);
|
||||
}
|
||||
else
|
||||
{
|
||||
headsetIntentFilter = new IntentFilter(Intent.ACTION_HEADSET_PLUG);
|
||||
}
|
||||
UApp.Companion.applicationContext().registerReceiver(headsetEventReceiver, headsetIntentFilter);
|
||||
}
|
||||
|
||||
public void handleKeyEvent(KeyEvent event)
|
||||
{
|
||||
if (event.getAction() != KeyEvent.ACTION_DOWN || event.getRepeatCount() > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
final int keyCode;
|
||||
int receivedKeyCode = event.getKeyCode();
|
||||
// Translate PLAY and PAUSE codes to PLAY_PAUSE to improve compatibility with old Bluetooth devices
|
||||
if (Util.getSingleButtonPlayPause() &&
|
||||
(receivedKeyCode == KeyEvent.KEYCODE_MEDIA_PLAY ||
|
||||
receivedKeyCode == KeyEvent.KEYCODE_MEDIA_PAUSE)) {
|
||||
Timber.i("Single button Play/Pause is set, rewriting keyCode to PLAY_PAUSE");
|
||||
keyCode = KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE;
|
||||
}
|
||||
else keyCode = receivedKeyCode;
|
||||
|
||||
boolean autoStart = (keyCode == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE ||
|
||||
keyCode == KeyEvent.KEYCODE_MEDIA_PLAY ||
|
||||
keyCode == KeyEvent.KEYCODE_HEADSETHOOK ||
|
||||
keyCode == KeyEvent.KEYCODE_MEDIA_PREVIOUS ||
|
||||
keyCode == KeyEvent.KEYCODE_MEDIA_NEXT);
|
||||
|
||||
// We can receive intents (e.g. MediaButton) when everything is stopped, so we need to start
|
||||
onCreate(autoStart, () -> {
|
||||
switch (keyCode)
|
||||
{
|
||||
case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE:
|
||||
case KeyEvent.KEYCODE_HEADSETHOOK:
|
||||
mediaPlayerController.togglePlayPause();
|
||||
break;
|
||||
case KeyEvent.KEYCODE_MEDIA_PREVIOUS:
|
||||
mediaPlayerController.previous();
|
||||
break;
|
||||
case KeyEvent.KEYCODE_MEDIA_NEXT:
|
||||
mediaPlayerController.next();
|
||||
break;
|
||||
case KeyEvent.KEYCODE_MEDIA_STOP:
|
||||
mediaPlayerController.stop();
|
||||
break;
|
||||
case KeyEvent.KEYCODE_MEDIA_PLAY:
|
||||
if (mediaPlayerController.getPlayerState() == PlayerState.IDLE)
|
||||
{
|
||||
mediaPlayerController.play();
|
||||
}
|
||||
else if (mediaPlayerController.getPlayerState() != PlayerState.STARTED)
|
||||
{
|
||||
mediaPlayerController.start();
|
||||
}
|
||||
break;
|
||||
case KeyEvent.KEYCODE_MEDIA_PAUSE:
|
||||
mediaPlayerController.pause();
|
||||
break;
|
||||
case KeyEvent.KEYCODE_1:
|
||||
mediaPlayerController.setSongRating(1);
|
||||
break;
|
||||
case KeyEvent.KEYCODE_2:
|
||||
mediaPlayerController.setSongRating(2);
|
||||
break;
|
||||
case KeyEvent.KEYCODE_3:
|
||||
mediaPlayerController.setSongRating(3);
|
||||
break;
|
||||
case KeyEvent.KEYCODE_4:
|
||||
mediaPlayerController.setSongRating(4);
|
||||
break;
|
||||
case KeyEvent.KEYCODE_5:
|
||||
mediaPlayerController.setSongRating(5);
|
||||
break;
|
||||
case KeyEvent.KEYCODE_STAR:
|
||||
mediaPlayerController.toggleSongStarred();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* This function processes the intent that could come from other applications.
|
||||
*/
|
||||
private void handleUltrasonicIntent(final String intentAction)
|
||||
{
|
||||
final boolean isRunning = created;
|
||||
// If Ultrasonic is not running, do nothing to stop or pause
|
||||
if (!isRunning && (intentAction.equals(Constants.CMD_PAUSE) ||
|
||||
intentAction.equals(Constants.CMD_STOP))) return;
|
||||
|
||||
boolean autoStart = (intentAction.equals(Constants.CMD_PLAY) ||
|
||||
intentAction.equals(Constants.CMD_RESUME_OR_PLAY) ||
|
||||
intentAction.equals(Constants.CMD_TOGGLEPAUSE) ||
|
||||
intentAction.equals(Constants.CMD_PREVIOUS) ||
|
||||
intentAction.equals(Constants.CMD_NEXT));
|
||||
|
||||
// We can receive intents when everything is stopped, so we need to start
|
||||
onCreate(autoStart, () -> {
|
||||
switch(intentAction)
|
||||
{
|
||||
case Constants.CMD_PLAY:
|
||||
mediaPlayerController.play();
|
||||
break;
|
||||
case Constants.CMD_RESUME_OR_PLAY:
|
||||
// If Ultrasonic wasn't running, the autoStart is enough to resume, no need to call anything
|
||||
if (isRunning) mediaPlayerController.resumeOrPlay();
|
||||
break;
|
||||
case Constants.CMD_NEXT:
|
||||
mediaPlayerController.next();
|
||||
break;
|
||||
case Constants.CMD_PREVIOUS:
|
||||
mediaPlayerController.previous();
|
||||
break;
|
||||
case Constants.CMD_TOGGLEPAUSE:
|
||||
mediaPlayerController.togglePlayPause();
|
||||
break;
|
||||
case Constants.CMD_STOP:
|
||||
// TODO: There is a stop() function, shouldn't we use that?
|
||||
mediaPlayerController.pause();
|
||||
mediaPlayerController.seekTo(0);
|
||||
break;
|
||||
case Constants.CMD_PAUSE:
|
||||
mediaPlayerController.pause();
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -125,8 +125,6 @@ public final class Constants
|
|||
public static final String PREFERENCES_KEY_DEFAULT_SHARE_GREETING = "sharingDefaultGreeting";
|
||||
public static final String PREFERENCES_KEY_DEFAULT_SHARE_EXPIRATION = "sharingDefaultExpiration";
|
||||
public static final String PREFERENCES_KEY_SHOW_ALL_SONGS_BY_ARTIST = "showAllSongsByArtist";
|
||||
public static final String PREFERENCES_KEY_IMAGE_LOADER_CONCURRENCY = "imageLoaderConcurrency";
|
||||
public static final String PREFERENCES_KEY_FF_IMAGE_LOADER = "ff_new_image_loader";
|
||||
public static final String PREFERENCES_KEY_USE_FIVE_STAR_RATING = "use_five_star_rating";
|
||||
public static final String PREFERENCES_KEY_CATEGORY_NOTIFICATIONS = "notificationsCategory";
|
||||
public static final String PREFERENCES_KEY_FIRST_RUN_EXECUTED = "firstRunExecuted";
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -4,6 +4,8 @@ import org.koin.android.ext.koin.androidContext
|
|||
import org.koin.dsl.module
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
|
||||
import org.moire.ultrasonic.util.MediaSessionEventDistributor
|
||||
import org.moire.ultrasonic.util.MediaSessionHandler
|
||||
import org.moire.ultrasonic.util.NowPlayingEventDistributor
|
||||
import org.moire.ultrasonic.util.PermissionUtil
|
||||
import org.moire.ultrasonic.util.ThemeChangedEventDistributor
|
||||
|
@ -17,4 +19,6 @@ val applicationModule = module {
|
|||
single { PermissionUtil(androidContext()) }
|
||||
single { NowPlayingEventDistributor() }
|
||||
single { ThemeChangedEventDistributor() }
|
||||
single { MediaSessionEventDistributor() }
|
||||
single { MediaSessionHandler() }
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
package org.moire.ultrasonic.di
|
||||
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.dsl.module
|
||||
import org.moire.ultrasonic.service.AudioFocusHandler
|
||||
import org.moire.ultrasonic.service.DownloadQueueSerializer
|
||||
|
@ -17,12 +16,12 @@ import org.moire.ultrasonic.util.ShufflePlayBuffer
|
|||
*/
|
||||
val mediaPlayerModule = module {
|
||||
single { JukeboxMediaPlayer(get()) }
|
||||
single { MediaPlayerLifecycleSupport(get(), get(), get()) }
|
||||
single { DownloadQueueSerializer(androidContext()) }
|
||||
single { MediaPlayerLifecycleSupport() }
|
||||
single { DownloadQueueSerializer() }
|
||||
single { ExternalStorageMonitor() }
|
||||
single { ShufflePlayBuffer() }
|
||||
single { Downloader(get(), get(), get()) }
|
||||
single { LocalMediaPlayer(get(), androidContext()) }
|
||||
single { LocalMediaPlayer() }
|
||||
single { AudioFocusHandler(get()) }
|
||||
|
||||
// TODO Ideally this can be cleaned up when all circular references are removed.
|
||||
|
|
|
@ -313,7 +313,7 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon
|
|||
}
|
||||
|
||||
repeatButton.setOnClickListener {
|
||||
val repeatMode = mediaPlayerController.repeatMode?.next()
|
||||
val repeatMode = mediaPlayerController.repeatMode.next()
|
||||
mediaPlayerController.repeatMode = repeatMode
|
||||
onDownloadListChanged()
|
||||
when (repeatMode) {
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,112 @@
|
|||
/*
|
||||
* DownloadQueueSerializer.kt
|
||||
* Copyright (C) 2009-2021 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
package org.moire.ultrasonic.service
|
||||
|
||||
import android.content.Context
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.locks.Lock
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.util.Constants
|
||||
import org.moire.ultrasonic.util.FileUtil
|
||||
import org.moire.ultrasonic.util.MediaSessionHandler
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* This class is responsible for the serialization / deserialization
|
||||
* of the DownloadQueue (playlist) to the filesystem.
|
||||
* It also serializes the player state e.g. current playing number and play position.
|
||||
*/
|
||||
class DownloadQueueSerializer : KoinComponent {
|
||||
|
||||
private val context by inject<Context>()
|
||||
private val mediaSessionHandler by inject<MediaSessionHandler>()
|
||||
|
||||
val lock: Lock = ReentrantLock()
|
||||
val setup = AtomicBoolean(false)
|
||||
|
||||
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
fun serializeDownloadQueue(
|
||||
songs: Iterable<DownloadFile>,
|
||||
currentPlayingIndex: Int,
|
||||
currentPlayingPosition: Int
|
||||
) {
|
||||
if (!setup.get()) return
|
||||
|
||||
appScope.launch {
|
||||
if (lock.tryLock()) {
|
||||
try {
|
||||
serializeDownloadQueueNow(songs, currentPlayingIndex, currentPlayingPosition)
|
||||
} finally {
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun serializeDownloadQueueNow(
|
||||
songs: Iterable<DownloadFile>,
|
||||
currentPlayingIndex: Int,
|
||||
currentPlayingPosition: Int
|
||||
) {
|
||||
val state = State()
|
||||
|
||||
for (downloadFile in songs) {
|
||||
state.songs.add(downloadFile.song)
|
||||
}
|
||||
|
||||
state.currentPlayingIndex = currentPlayingIndex
|
||||
state.currentPlayingPosition = currentPlayingPosition
|
||||
|
||||
Timber.i(
|
||||
"Serialized currentPlayingIndex: %d, currentPlayingPosition: %d",
|
||||
state.currentPlayingIndex,
|
||||
state.currentPlayingPosition
|
||||
)
|
||||
|
||||
FileUtil.serialize(context, state, Constants.FILENAME_DOWNLOADS_SER)
|
||||
|
||||
// This is called here because the queue is usually serialized after a change
|
||||
mediaSessionHandler.updateMediaSessionQueue(state.songs)
|
||||
}
|
||||
|
||||
fun deserializeDownloadQueue(afterDeserialized: Consumer<State?>) {
|
||||
|
||||
appScope.launch {
|
||||
try {
|
||||
lock.lock()
|
||||
deserializeDownloadQueueNow(afterDeserialized)
|
||||
setup.set(true)
|
||||
} finally {
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun deserializeDownloadQueueNow(afterDeserialized: Consumer<State?>) {
|
||||
|
||||
val state = FileUtil.deserialize<State>(
|
||||
context, Constants.FILENAME_DOWNLOADS_SER
|
||||
) ?: return
|
||||
|
||||
Timber.i(
|
||||
"Deserialized currentPlayingIndex: %d, currentPlayingPosition: %d ",
|
||||
state.currentPlayingIndex,
|
||||
state.currentPlayingPosition
|
||||
)
|
||||
|
||||
mediaSessionHandler.updateMediaSessionQueue(state.songs)
|
||||
afterDeserialized.accept(state)
|
||||
}
|
||||
}
|
|
@ -26,12 +26,15 @@ import java.net.URLEncoder
|
|||
import java.util.Locale
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.audiofx.EqualizerController
|
||||
import org.moire.ultrasonic.audiofx.VisualizerController
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
|
||||
import org.moire.ultrasonic.domain.PlayerState
|
||||
import org.moire.ultrasonic.util.CancellableTask
|
||||
import org.moire.ultrasonic.util.Constants
|
||||
import org.moire.ultrasonic.util.MediaSessionHandler
|
||||
import org.moire.ultrasonic.util.StreamProxy
|
||||
import org.moire.ultrasonic.util.Util
|
||||
import timber.log.Timber
|
||||
|
@ -39,10 +42,12 @@ import timber.log.Timber
|
|||
/**
|
||||
* Represents a Media Player which uses the mobile's resources for playback
|
||||
*/
|
||||
class LocalMediaPlayer(
|
||||
private val audioFocusHandler: AudioFocusHandler,
|
||||
private val context: Context
|
||||
) {
|
||||
@Suppress("TooManyFunctions")
|
||||
class LocalMediaPlayer : KoinComponent {
|
||||
|
||||
private val audioFocusHandler by inject<AudioFocusHandler>()
|
||||
private val context by inject<Context>()
|
||||
private val mediaSessionHandler by inject<MediaSessionHandler>()
|
||||
|
||||
@JvmField
|
||||
var onCurrentPlayingChanged: ((DownloadFile?) -> Unit?)? = null
|
||||
|
@ -125,6 +130,10 @@ class LocalMediaPlayer(
|
|||
}
|
||||
|
||||
fun release() {
|
||||
// Calling reset() will result in changing this player's state. If we allow
|
||||
// the onPlayerStateChanged callback, then the state change will cause this
|
||||
// to resurrect the media session which has just been destroyed.
|
||||
onPlayerStateChanged = null
|
||||
reset()
|
||||
try {
|
||||
val i = Intent(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION)
|
||||
|
@ -167,7 +176,7 @@ class LocalMediaPlayer(
|
|||
val mainHandler = Handler(context.mainLooper)
|
||||
|
||||
val myRunnable = Runnable {
|
||||
onPlayerStateChanged!!(playerState, currentPlaying)
|
||||
onPlayerStateChanged?.invoke(playerState, currentPlaying)
|
||||
}
|
||||
mainHandler.post(myRunnable)
|
||||
}
|
||||
|
@ -701,8 +710,11 @@ class LocalMediaPlayer(
|
|||
try {
|
||||
if (playerState === PlayerState.STARTED) {
|
||||
cachedPosition = mediaPlayer.currentPosition
|
||||
mediaSessionHandler.updateMediaSessionPlaybackPosition(
|
||||
cachedPosition.toLong()
|
||||
)
|
||||
}
|
||||
Util.sleepQuietly(50L)
|
||||
Util.sleepQuietly(100L)
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Crashed getting current position")
|
||||
isRunning = false
|
||||
|
|
|
@ -247,10 +247,10 @@ class MediaPlayerController(
|
|||
}
|
||||
|
||||
@set:Synchronized
|
||||
var repeatMode: RepeatMode?
|
||||
get() = Util.getRepeatMode()
|
||||
var repeatMode: RepeatMode
|
||||
get() = Util.repeatMode
|
||||
set(repeatMode) {
|
||||
Util.setRepeatMode(repeatMode)
|
||||
Util.repeatMode = repeatMode
|
||||
val mediaPlayerService = runningInstance
|
||||
mediaPlayerService?.setNextPlaying()
|
||||
}
|
||||
|
|
|
@ -0,0 +1,278 @@
|
|||
/*
|
||||
* MediaPlayerLifecycleSupport.kt
|
||||
* Copyright (C) 2009-2021 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
package org.moire.ultrasonic.service
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import android.view.KeyEvent
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.app.UApp.Companion.applicationContext
|
||||
import org.moire.ultrasonic.domain.PlayerState
|
||||
import org.moire.ultrasonic.util.CacheCleaner
|
||||
import org.moire.ultrasonic.util.Constants
|
||||
import org.moire.ultrasonic.util.MediaSessionEventDistributor
|
||||
import org.moire.ultrasonic.util.MediaSessionEventListener
|
||||
import org.moire.ultrasonic.util.Util
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* This class is responsible for handling received events for the Media Player implementation
|
||||
*
|
||||
* @author Sindre Mehus
|
||||
*/
|
||||
class MediaPlayerLifecycleSupport : KoinComponent {
|
||||
private val downloadQueueSerializer by inject<DownloadQueueSerializer>()
|
||||
private val mediaPlayerController by inject<MediaPlayerController>()
|
||||
private val downloader by inject<Downloader>()
|
||||
private val mediaSessionEventDistributor by inject<MediaSessionEventDistributor>()
|
||||
|
||||
private var created = false
|
||||
private var headsetEventReceiver: BroadcastReceiver? = null
|
||||
private lateinit var mediaSessionEventListener: MediaSessionEventListener
|
||||
|
||||
fun onCreate() {
|
||||
onCreate(false, null)
|
||||
}
|
||||
|
||||
private fun onCreate(autoPlay: Boolean, afterCreated: Runnable?) {
|
||||
|
||||
if (created) {
|
||||
afterCreated?.run()
|
||||
return
|
||||
}
|
||||
|
||||
mediaSessionEventListener = object : MediaSessionEventListener {
|
||||
override fun onMediaButtonEvent(keyEvent: KeyEvent?) {
|
||||
if (keyEvent != null) handleKeyEvent(keyEvent)
|
||||
}
|
||||
}
|
||||
|
||||
mediaSessionEventDistributor.subscribe(mediaSessionEventListener)
|
||||
registerHeadsetReceiver()
|
||||
mediaPlayerController.onCreate()
|
||||
if (autoPlay) mediaPlayerController.preload()
|
||||
|
||||
downloadQueueSerializer.deserializeDownloadQueue(object : Consumer<State?>() {
|
||||
override fun accept(state: State?) {
|
||||
mediaPlayerController.restore(
|
||||
state!!.songs,
|
||||
state.currentPlayingIndex,
|
||||
state.currentPlayingPosition,
|
||||
autoPlay,
|
||||
false
|
||||
)
|
||||
|
||||
// Work-around: Serialize again, as the restore() method creates a
|
||||
// serialization without current playing info.
|
||||
downloadQueueSerializer.serializeDownloadQueue(
|
||||
downloader.downloadList,
|
||||
downloader.currentPlayingIndex,
|
||||
mediaPlayerController.playerPosition
|
||||
)
|
||||
afterCreated?.run()
|
||||
}
|
||||
})
|
||||
|
||||
CacheCleaner().clean()
|
||||
created = true
|
||||
Timber.i("LifecycleSupport created")
|
||||
}
|
||||
|
||||
fun onDestroy() {
|
||||
|
||||
if (!created) return
|
||||
|
||||
downloadQueueSerializer.serializeDownloadQueueNow(
|
||||
downloader.downloadList,
|
||||
downloader.currentPlayingIndex,
|
||||
mediaPlayerController.playerPosition
|
||||
)
|
||||
|
||||
mediaSessionEventDistributor.unsubscribe(mediaSessionEventListener)
|
||||
|
||||
mediaPlayerController.clear(false)
|
||||
applicationContext().unregisterReceiver(headsetEventReceiver)
|
||||
mediaPlayerController.onDestroy()
|
||||
|
||||
created = false
|
||||
Timber.i("LifecycleSupport destroyed")
|
||||
}
|
||||
|
||||
fun receiveIntent(intent: Intent?) {
|
||||
|
||||
if (intent == null) return
|
||||
|
||||
val intentAction = intent.action
|
||||
if (intentAction == null || intentAction.isEmpty()) return
|
||||
|
||||
Timber.i("Received intent: %s", intentAction)
|
||||
|
||||
if (intentAction == Constants.CMD_PROCESS_KEYCODE) {
|
||||
if (intent.extras != null) {
|
||||
val event = intent.extras!![Intent.EXTRA_KEY_EVENT] as KeyEvent?
|
||||
event?.let { handleKeyEvent(it) }
|
||||
}
|
||||
} else {
|
||||
handleUltrasonicIntent(intentAction)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Headset Intent Receiver is responsible for resuming playback when a headset is inserted
|
||||
* and pausing it when it is removed.
|
||||
* Unfortunately this Intent can't be registered in the AndroidManifest, so it works only
|
||||
* while Ultrasonic is running.
|
||||
*/
|
||||
private fun registerHeadsetReceiver() {
|
||||
|
||||
val sp = Util.getPreferences()
|
||||
val context = applicationContext()
|
||||
val spKey = context
|
||||
.getString(R.string.settings_playback_resume_play_on_headphones_plug)
|
||||
|
||||
headsetEventReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val extras = intent.extras ?: return
|
||||
|
||||
Timber.i("Headset event for: %s", extras["name"])
|
||||
|
||||
val state = extras.getInt("state")
|
||||
|
||||
if (state == 0) {
|
||||
if (!mediaPlayerController.isJukeboxEnabled) {
|
||||
mediaPlayerController.pause()
|
||||
}
|
||||
} else if (state == 1) {
|
||||
if (!mediaPlayerController.isJukeboxEnabled &&
|
||||
sp.getBoolean(
|
||||
spKey,
|
||||
false
|
||||
) && mediaPlayerController.playerState === PlayerState.PAUSED
|
||||
) {
|
||||
mediaPlayerController.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val headsetIntentFilter: IntentFilter =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
IntentFilter(AudioManager.ACTION_HEADSET_PLUG)
|
||||
} else {
|
||||
IntentFilter(Intent.ACTION_HEADSET_PLUG)
|
||||
}
|
||||
|
||||
applicationContext().registerReceiver(headsetEventReceiver, headsetIntentFilter)
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber", "ComplexMethod")
|
||||
private fun handleKeyEvent(event: KeyEvent) {
|
||||
|
||||
if (event.action != KeyEvent.ACTION_DOWN || event.repeatCount > 0) return
|
||||
|
||||
val keyCode: Int
|
||||
val receivedKeyCode = event.keyCode
|
||||
|
||||
// Translate PLAY and PAUSE codes to PLAY_PAUSE to improve compatibility with old Bluetooth devices
|
||||
keyCode = if (Util.getSingleButtonPlayPause() && (
|
||||
receivedKeyCode == KeyEvent.KEYCODE_MEDIA_PLAY ||
|
||||
receivedKeyCode == KeyEvent.KEYCODE_MEDIA_PAUSE
|
||||
)
|
||||
) {
|
||||
Timber.i("Single button Play/Pause is set, rewriting keyCode to PLAY_PAUSE")
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE
|
||||
} else receivedKeyCode
|
||||
|
||||
val autoStart =
|
||||
keyCode == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE ||
|
||||
keyCode == KeyEvent.KEYCODE_MEDIA_PLAY ||
|
||||
keyCode == KeyEvent.KEYCODE_HEADSETHOOK ||
|
||||
keyCode == KeyEvent.KEYCODE_MEDIA_PREVIOUS ||
|
||||
keyCode == KeyEvent.KEYCODE_MEDIA_NEXT
|
||||
|
||||
// We can receive intents (e.g. MediaButton) when everything is stopped, so we need to start
|
||||
onCreate(autoStart) {
|
||||
when (keyCode) {
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE,
|
||||
KeyEvent.KEYCODE_HEADSETHOOK -> mediaPlayerController.togglePlayPause()
|
||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> mediaPlayerController.previous()
|
||||
KeyEvent.KEYCODE_MEDIA_NEXT -> mediaPlayerController.next()
|
||||
KeyEvent.KEYCODE_MEDIA_STOP -> mediaPlayerController.stop()
|
||||
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY ->
|
||||
if (mediaPlayerController.playerState === PlayerState.IDLE) {
|
||||
mediaPlayerController.play()
|
||||
} else if (mediaPlayerController.playerState !== PlayerState.STARTED) {
|
||||
mediaPlayerController.start()
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_MEDIA_PAUSE -> mediaPlayerController.pause()
|
||||
KeyEvent.KEYCODE_1 -> mediaPlayerController.setSongRating(1)
|
||||
KeyEvent.KEYCODE_2 -> mediaPlayerController.setSongRating(2)
|
||||
KeyEvent.KEYCODE_3 -> mediaPlayerController.setSongRating(3)
|
||||
KeyEvent.KEYCODE_4 -> mediaPlayerController.setSongRating(4)
|
||||
KeyEvent.KEYCODE_5 -> mediaPlayerController.setSongRating(5)
|
||||
KeyEvent.KEYCODE_STAR -> mediaPlayerController.toggleSongStarred()
|
||||
else -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function processes the intent that could come from other applications.
|
||||
*/
|
||||
@Suppress("ComplexMethod")
|
||||
private fun handleUltrasonicIntent(intentAction: String) {
|
||||
|
||||
val isRunning = created
|
||||
|
||||
// If Ultrasonic is not running, do nothing to stop or pause
|
||||
if (
|
||||
!isRunning && (
|
||||
intentAction == Constants.CMD_PAUSE ||
|
||||
intentAction == Constants.CMD_STOP
|
||||
)
|
||||
) return
|
||||
|
||||
val autoStart =
|
||||
intentAction == Constants.CMD_PLAY ||
|
||||
intentAction == Constants.CMD_RESUME_OR_PLAY ||
|
||||
intentAction == Constants.CMD_TOGGLEPAUSE ||
|
||||
intentAction == Constants.CMD_PREVIOUS ||
|
||||
intentAction == Constants.CMD_NEXT
|
||||
|
||||
// We can receive intents when everything is stopped, so we need to start
|
||||
onCreate(autoStart) {
|
||||
when (intentAction) {
|
||||
Constants.CMD_PLAY -> mediaPlayerController.play()
|
||||
Constants.CMD_RESUME_OR_PLAY ->
|
||||
// If Ultrasonic wasn't running, the autoStart is enough to resume,
|
||||
// no need to call anything
|
||||
if (isRunning) mediaPlayerController.resumeOrPlay()
|
||||
|
||||
Constants.CMD_NEXT -> mediaPlayerController.next()
|
||||
Constants.CMD_PREVIOUS -> mediaPlayerController.previous()
|
||||
Constants.CMD_TOGGLEPAUSE -> mediaPlayerController.togglePlayPause()
|
||||
|
||||
Constants.CMD_STOP -> {
|
||||
// TODO: There is a stop() function, shouldn't we use that?
|
||||
mediaPlayerController.pause()
|
||||
mediaPlayerController.seekTo(0)
|
||||
}
|
||||
Constants.CMD_PAUSE -> mediaPlayerController.pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -12,17 +12,15 @@ import android.app.NotificationChannel
|
|||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import android.view.KeyEvent
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import kotlin.collections.ArrayList
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.activity.NavigationActivity
|
||||
|
@ -35,9 +33,11 @@ import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X1
|
|||
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X2
|
||||
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X3
|
||||
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X4
|
||||
import org.moire.ultrasonic.receiver.MediaButtonIntentReceiver
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
||||
import org.moire.ultrasonic.util.Constants
|
||||
import org.moire.ultrasonic.util.MediaSessionEventDistributor
|
||||
import org.moire.ultrasonic.util.MediaSessionEventListener
|
||||
import org.moire.ultrasonic.util.MediaSessionHandler
|
||||
import org.moire.ultrasonic.util.NowPlayingEventDistributor
|
||||
import org.moire.ultrasonic.util.ShufflePlayBuffer
|
||||
import org.moire.ultrasonic.util.SimpleServiceBinder
|
||||
|
@ -59,15 +59,17 @@ class MediaPlayerService : Service() {
|
|||
private val downloader by inject<Downloader>()
|
||||
private val localMediaPlayer by inject<LocalMediaPlayer>()
|
||||
private val nowPlayingEventDistributor by inject<NowPlayingEventDistributor>()
|
||||
private val mediaPlayerLifecycleSupport by inject<MediaPlayerLifecycleSupport>()
|
||||
private val mediaSessionEventDistributor by inject<MediaSessionEventDistributor>()
|
||||
private val mediaSessionHandler by inject<MediaSessionHandler>()
|
||||
|
||||
private var mediaSession: MediaSessionCompat? = null
|
||||
private var mediaSessionToken: MediaSessionCompat.Token? = null
|
||||
private var isInForeground = false
|
||||
private var notificationBuilder: NotificationCompat.Builder? = null
|
||||
private lateinit var mediaSessionEventListener: MediaSessionEventListener
|
||||
|
||||
private val repeatMode: RepeatMode
|
||||
get() = Util.getRepeatMode()
|
||||
get() = Util.repeatMode
|
||||
|
||||
override fun onBind(intent: Intent): IBinder {
|
||||
return binder
|
||||
|
@ -95,6 +97,19 @@ class MediaPlayerService : Service() {
|
|||
|
||||
localMediaPlayer.onNextSongRequested = Runnable { setNextPlaying() }
|
||||
|
||||
mediaSessionEventListener = object : MediaSessionEventListener {
|
||||
override fun onMediaSessionTokenCreated(token: MediaSessionCompat.Token) {
|
||||
mediaSessionToken = token
|
||||
}
|
||||
|
||||
override fun onSkipToQueueItemRequested(id: Long) {
|
||||
play(id.toInt())
|
||||
}
|
||||
}
|
||||
|
||||
mediaSessionEventDistributor.subscribe(mediaSessionEventListener)
|
||||
mediaSessionHandler.initialize()
|
||||
|
||||
// Create Notification Channel
|
||||
createNotificationChannel()
|
||||
|
||||
|
@ -114,9 +129,13 @@ class MediaPlayerService : Service() {
|
|||
super.onDestroy()
|
||||
instance = null
|
||||
try {
|
||||
mediaSessionEventDistributor.unsubscribe(mediaSessionEventListener)
|
||||
mediaSessionHandler.release()
|
||||
|
||||
localMediaPlayer.release()
|
||||
downloader.stop()
|
||||
shufflePlayBuffer.onDestroy()
|
||||
|
||||
mediaSession?.release()
|
||||
mediaSession = null
|
||||
} catch (ignored: Throwable) {
|
||||
|
@ -368,7 +387,11 @@ class MediaPlayerService : Service() {
|
|||
val context = this@MediaPlayerService
|
||||
|
||||
// Notify MediaSession
|
||||
updateMediaSession(currentPlaying, playerState)
|
||||
mediaSessionHandler.updateMediaSession(
|
||||
currentPlaying,
|
||||
downloader.currentPlayingIndex.toLong(),
|
||||
playerState
|
||||
)
|
||||
|
||||
if (playerState === PlayerState.PAUSED) {
|
||||
downloadQueueSerializer.serializeDownloadQueue(
|
||||
|
@ -468,90 +491,6 @@ class MediaPlayerService : Service() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun updateMediaSession(currentPlaying: DownloadFile?, playerState: PlayerState) {
|
||||
Timber.d("Updating the MediaSession")
|
||||
|
||||
if (mediaSession == null) initMediaSessions()
|
||||
|
||||
// Set Metadata
|
||||
val metadata = MediaMetadataCompat.Builder()
|
||||
if (currentPlaying != null) {
|
||||
try {
|
||||
val song = currentPlaying.song
|
||||
val cover = BitmapUtils.getAlbumArtBitmapFromDisk(
|
||||
song, Util.getMinDisplayMetric()
|
||||
)
|
||||
metadata.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, -1L)
|
||||
metadata.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, song.artist)
|
||||
metadata.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, song.artist)
|
||||
metadata.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album)
|
||||
metadata.putString(MediaMetadataCompat.METADATA_KEY_TITLE, song.title)
|
||||
metadata.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, cover)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error setting the metadata")
|
||||
}
|
||||
}
|
||||
|
||||
// Save the metadata
|
||||
mediaSession!!.setMetadata(metadata.build())
|
||||
|
||||
// Create playback State
|
||||
val playbackState = PlaybackStateCompat.Builder()
|
||||
val state: Int
|
||||
val isActive: Boolean
|
||||
|
||||
var actions: Long = PlaybackStateCompat.ACTION_PLAY_PAUSE or
|
||||
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
|
||||
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
|
||||
|
||||
// Map our playerState to native PlaybackState
|
||||
// TODO: Synchronize these APIs
|
||||
when (playerState) {
|
||||
PlayerState.STARTED -> {
|
||||
state = PlaybackStateCompat.STATE_PLAYING
|
||||
isActive = true
|
||||
actions = actions or
|
||||
PlaybackStateCompat.ACTION_PAUSE or
|
||||
PlaybackStateCompat.ACTION_STOP
|
||||
}
|
||||
PlayerState.COMPLETED,
|
||||
PlayerState.STOPPED -> {
|
||||
isActive = false
|
||||
state = PlaybackStateCompat.STATE_STOPPED
|
||||
}
|
||||
PlayerState.IDLE -> {
|
||||
isActive = false
|
||||
state = PlaybackStateCompat.STATE_NONE
|
||||
actions = 0L
|
||||
}
|
||||
PlayerState.PAUSED -> {
|
||||
isActive = true
|
||||
state = PlaybackStateCompat.STATE_PAUSED
|
||||
actions = actions or
|
||||
PlaybackStateCompat.ACTION_PLAY or
|
||||
PlaybackStateCompat.ACTION_STOP
|
||||
}
|
||||
else -> {
|
||||
// These are the states PREPARING, PREPARED & DOWNLOADING
|
||||
isActive = true
|
||||
state = PlaybackStateCompat.STATE_PAUSED
|
||||
}
|
||||
}
|
||||
|
||||
playbackState.setState(state, PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN, 1.0f)
|
||||
|
||||
// Set actions
|
||||
playbackState.setActions(actions)
|
||||
|
||||
// Save the playback state
|
||||
mediaSession!!.setPlaybackState(playbackState.build())
|
||||
|
||||
// Set Active state
|
||||
mediaSession!!.isActive = isActive
|
||||
|
||||
Timber.d("Setting the MediaSession to active = %s", isActive)
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
|
||||
|
@ -604,7 +543,11 @@ class MediaPlayerService : Service() {
|
|||
// Init
|
||||
val context = applicationContext
|
||||
val song = currentPlaying?.song
|
||||
val stopIntent = getPendingIntentForMediaAction(context, KeyEvent.KEYCODE_MEDIA_STOP, 100)
|
||||
val stopIntent = Util.getPendingIntentForMediaAction(
|
||||
context,
|
||||
KeyEvent.KEYCODE_MEDIA_STOP,
|
||||
100
|
||||
)
|
||||
|
||||
// We should use a single notification builder, otherwise the notification may not be updated
|
||||
if (notificationBuilder == null) {
|
||||
|
@ -723,7 +666,7 @@ class MediaPlayerService : Service() {
|
|||
else -> return null
|
||||
}
|
||||
|
||||
val pendingIntent = getPendingIntentForMediaAction(context, keycode, requestCode)
|
||||
val pendingIntent = Util.getPendingIntentForMediaAction(context, keycode, requestCode)
|
||||
return NotificationCompat.Action.Builder(icon, label, pendingIntent).build()
|
||||
}
|
||||
|
||||
|
@ -734,7 +677,7 @@ class MediaPlayerService : Service() {
|
|||
): NotificationCompat.Action {
|
||||
val isPlaying = playerState === PlayerState.STARTED
|
||||
val keycode = KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE
|
||||
val pendingIntent = getPendingIntentForMediaAction(context, keycode, requestCode)
|
||||
val pendingIntent = Util.getPendingIntentForMediaAction(context, keycode, requestCode)
|
||||
val label: String
|
||||
val icon: Int
|
||||
|
||||
|
@ -767,7 +710,7 @@ class MediaPlayerService : Service() {
|
|||
icon = R.drawable.ic_star_hollow_dark
|
||||
}
|
||||
|
||||
val pendingIntent = getPendingIntentForMediaAction(context, keyCode, requestCode)
|
||||
val pendingIntent = Util.getPendingIntentForMediaAction(context, keyCode, requestCode)
|
||||
return NotificationCompat.Action.Builder(icon, label, pendingIntent).build()
|
||||
}
|
||||
|
||||
|
@ -779,126 +722,11 @@ class MediaPlayerService : Service() {
|
|||
return PendingIntent.getActivity(this, 0, intent, flags)
|
||||
}
|
||||
|
||||
private fun getPendingIntentForMediaAction(
|
||||
context: Context,
|
||||
keycode: Int,
|
||||
requestCode: Int
|
||||
): PendingIntent {
|
||||
val intent = Intent(Constants.CMD_PROCESS_KEYCODE)
|
||||
val flags = PendingIntent.FLAG_UPDATE_CURRENT
|
||||
intent.setPackage(context.packageName)
|
||||
intent.putExtra(Intent.EXTRA_KEY_EVENT, KeyEvent(KeyEvent.ACTION_DOWN, keycode))
|
||||
return PendingIntent.getBroadcast(context, requestCode, intent, flags)
|
||||
}
|
||||
|
||||
private fun initMediaSessions() {
|
||||
@Suppress("MagicNumber")
|
||||
val keycode = 110
|
||||
|
||||
Timber.w("Creating media session")
|
||||
|
||||
mediaSession = MediaSessionCompat(applicationContext, "UltrasonicService")
|
||||
mediaSessionToken = mediaSession!!.sessionToken
|
||||
|
||||
updateMediaButtonReceiver()
|
||||
|
||||
mediaSession!!.setCallback(object : MediaSessionCompat.Callback() {
|
||||
override fun onPlay() {
|
||||
super.onPlay()
|
||||
|
||||
getPendingIntentForMediaAction(
|
||||
applicationContext,
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY,
|
||||
keycode
|
||||
).send()
|
||||
|
||||
Timber.v("Media Session Callback: onPlay")
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
getPendingIntentForMediaAction(
|
||||
applicationContext,
|
||||
KeyEvent.KEYCODE_MEDIA_PAUSE,
|
||||
keycode
|
||||
).send()
|
||||
Timber.v("Media Session Callback: onPause")
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
getPendingIntentForMediaAction(
|
||||
applicationContext,
|
||||
KeyEvent.KEYCODE_MEDIA_STOP,
|
||||
keycode
|
||||
).send()
|
||||
Timber.v("Media Session Callback: onStop")
|
||||
}
|
||||
|
||||
override fun onSkipToNext() {
|
||||
super.onSkipToNext()
|
||||
getPendingIntentForMediaAction(
|
||||
applicationContext,
|
||||
KeyEvent.KEYCODE_MEDIA_NEXT,
|
||||
keycode
|
||||
).send()
|
||||
Timber.v("Media Session Callback: onSkipToNext")
|
||||
}
|
||||
|
||||
override fun onSkipToPrevious() {
|
||||
super.onSkipToPrevious()
|
||||
getPendingIntentForMediaAction(
|
||||
applicationContext,
|
||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS,
|
||||
keycode
|
||||
).send()
|
||||
Timber.v("Media Session Callback: onSkipToPrevious")
|
||||
}
|
||||
|
||||
override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean {
|
||||
// This probably won't be necessary once we implement more
|
||||
// of the modern media APIs, like the MediaController etc.
|
||||
val event = mediaButtonEvent.extras!!["android.intent.extra.KEY_EVENT"] as KeyEvent?
|
||||
mediaPlayerLifecycleSupport.handleKeyEvent(event)
|
||||
return true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun updateMediaButtonReceiver() {
|
||||
if (Util.getMediaButtonsEnabled()) {
|
||||
registerMediaButtonEventReceiver()
|
||||
} else {
|
||||
unregisterMediaButtonEventReceiver()
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerMediaButtonEventReceiver() {
|
||||
val component = ComponentName(packageName, MediaButtonIntentReceiver::class.java.name)
|
||||
val mediaButtonIntent = Intent(Intent.ACTION_MEDIA_BUTTON)
|
||||
mediaButtonIntent.component = component
|
||||
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
this,
|
||||
INTENT_CODE_MEDIA_BUTTON,
|
||||
mediaButtonIntent,
|
||||
PendingIntent.FLAG_CANCEL_CURRENT
|
||||
)
|
||||
|
||||
mediaSession?.setMediaButtonReceiver(pendingIntent)
|
||||
}
|
||||
|
||||
private fun unregisterMediaButtonEventReceiver() {
|
||||
mediaSession?.setMediaButtonReceiver(null)
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
companion object {
|
||||
private const val NOTIFICATION_CHANNEL_ID = "org.moire.ultrasonic"
|
||||
private const val NOTIFICATION_CHANNEL_NAME = "Ultrasonic background service"
|
||||
private const val NOTIFICATION_ID = 3033
|
||||
private const val INTENT_CODE_MEDIA_BUTTON = 161
|
||||
|
||||
private var instance: MediaPlayerService? = null
|
||||
private val instanceLock = Any()
|
||||
|
|
|
@ -40,13 +40,13 @@ class ShareHandler(val context: Context) {
|
|||
swipe: SwipeRefreshLayout?,
|
||||
cancellationToken: CancellationToken
|
||||
) {
|
||||
val askForDetails = Util.getShouldAskForShareDetails()
|
||||
val askForDetails = Util.shouldAskForShareDetails
|
||||
val shareDetails = ShareDetails()
|
||||
shareDetails.Entries = entries
|
||||
if (askForDetails) {
|
||||
showDialog(fragment, shareDetails, swipe, cancellationToken)
|
||||
} else {
|
||||
shareDetails.Description = Util.getDefaultShareDescription()
|
||||
shareDetails.Description = Util.defaultShareDescription
|
||||
shareDetails.Expiration = TimeSpan.getCurrentTime().add(
|
||||
Util.getDefaultShareExpirationInMillis(context)
|
||||
).totalMilliseconds
|
||||
|
@ -133,16 +133,16 @@ class ShareHandler(val context: Context) {
|
|||
}
|
||||
shareDetails.Description = shareDescription!!.text.toString()
|
||||
if (hideDialogCheckBox!!.isChecked) {
|
||||
Util.setShouldAskForShareDetails(false)
|
||||
Util.shouldAskForShareDetails = false
|
||||
}
|
||||
if (saveAsDefaultsCheckBox!!.isChecked) {
|
||||
val timeSpanType: String = timeSpanPicker!!.timeSpanType
|
||||
val timeSpanAmount: Int = timeSpanPicker!!.timeSpanAmount
|
||||
Util.setDefaultShareExpiration(
|
||||
Util.defaultShareExpiration =
|
||||
if (!noExpirationCheckBox!!.isChecked && timeSpanAmount > 0)
|
||||
String.format("%d:%s", timeSpanAmount, timeSpanType) else ""
|
||||
)
|
||||
Util.setDefaultShareDescription(shareDetails.Description)
|
||||
|
||||
Util.defaultShareDescription = shareDetails.Description
|
||||
}
|
||||
share(fragment, shareDetails, swipe, cancellationToken)
|
||||
}
|
||||
|
@ -157,8 +157,8 @@ class ShareHandler(val context: Context) {
|
|||
b ->
|
||||
timeSpanPicker!!.isEnabled = !b
|
||||
}
|
||||
val defaultDescription = Util.getDefaultShareDescription()
|
||||
val timeSpan = Util.getDefaultShareExpiration()
|
||||
val defaultDescription = Util.defaultShareDescription
|
||||
val timeSpan = Util.defaultShareExpiration
|
||||
val split = pattern.split(timeSpan)
|
||||
if (split.size == 2) {
|
||||
val timeSpanAmount = split[0].toInt()
|
||||
|
|
|
@ -0,0 +1,68 @@
|
|||
/*
|
||||
* MediaSessionEventDistributor.kt
|
||||
* Copyright (C) 2009-2021 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
package org.moire.ultrasonic.util
|
||||
|
||||
import android.os.Bundle
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import android.view.KeyEvent
|
||||
|
||||
/**
|
||||
* This class distributes MediaSession related events to its subscribers.
|
||||
* It is a primitive implementation of a pub-sub event bus
|
||||
*/
|
||||
class MediaSessionEventDistributor {
|
||||
var eventListenerList: MutableList<MediaSessionEventListener> =
|
||||
listOf<MediaSessionEventListener>().toMutableList()
|
||||
|
||||
var cachedToken: MediaSessionCompat.Token? = null
|
||||
|
||||
fun subscribe(listener: MediaSessionEventListener) {
|
||||
eventListenerList.add(listener)
|
||||
|
||||
synchronized(this) {
|
||||
if (cachedToken != null)
|
||||
listener.onMediaSessionTokenCreated(cachedToken!!)
|
||||
}
|
||||
}
|
||||
|
||||
fun unsubscribe(listener: MediaSessionEventListener) {
|
||||
eventListenerList.remove(listener)
|
||||
}
|
||||
|
||||
fun releaseCachedMediaSessionToken() {
|
||||
synchronized(this) {
|
||||
cachedToken = null
|
||||
}
|
||||
}
|
||||
|
||||
fun raiseMediaSessionTokenCreatedEvent(token: MediaSessionCompat.Token) {
|
||||
synchronized(this) {
|
||||
cachedToken = token
|
||||
eventListenerList.forEach { listener -> listener.onMediaSessionTokenCreated(token) }
|
||||
}
|
||||
}
|
||||
|
||||
fun raisePlayFromMediaIdRequestedEvent(mediaId: String?, extras: Bundle?) {
|
||||
eventListenerList.forEach {
|
||||
listener ->
|
||||
listener.onPlayFromMediaIdRequested(mediaId, extras)
|
||||
}
|
||||
}
|
||||
|
||||
fun raisePlayFromSearchRequestedEvent(query: String?, extras: Bundle?) {
|
||||
eventListenerList.forEach { listener -> listener.onPlayFromSearchRequested(query, extras) }
|
||||
}
|
||||
|
||||
fun raiseSkipToQueueItemRequestedEvent(id: Long) {
|
||||
eventListenerList.forEach { listener -> listener.onSkipToQueueItemRequested(id) }
|
||||
}
|
||||
|
||||
fun raiseMediaButtonEvent(keyEvent: KeyEvent?) {
|
||||
eventListenerList.forEach { listener -> listener.onMediaButtonEvent(keyEvent) }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* MediaSessionEventListener.kt
|
||||
* Copyright (C) 2009-2021 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
package org.moire.ultrasonic.util
|
||||
|
||||
import android.os.Bundle
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import android.view.KeyEvent
|
||||
|
||||
/**
|
||||
* Callback interface for MediaSession related event subscribers
|
||||
*/
|
||||
interface MediaSessionEventListener {
|
||||
fun onMediaSessionTokenCreated(token: MediaSessionCompat.Token) {}
|
||||
fun onPlayFromMediaIdRequested(mediaId: String?, extras: Bundle?) {}
|
||||
fun onPlayFromSearchRequested(query: String?, extras: Bundle?) {}
|
||||
fun onSkipToQueueItemRequested(id: Long) {}
|
||||
fun onMediaButtonEvent(keyEvent: KeyEvent?) {}
|
||||
}
|
|
@ -0,0 +1,323 @@
|
|||
/*
|
||||
* MediaSessionHandler.kt
|
||||
* Copyright (C) 2009-2021 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
package org.moire.ultrasonic.util
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import android.support.v4.media.session.PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN
|
||||
import android.view.KeyEvent
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.domain.MusicDirectory
|
||||
import org.moire.ultrasonic.domain.PlayerState
|
||||
import org.moire.ultrasonic.imageloader.BitmapUtils
|
||||
import org.moire.ultrasonic.receiver.MediaButtonIntentReceiver
|
||||
import org.moire.ultrasonic.service.DownloadFile
|
||||
import timber.log.Timber
|
||||
|
||||
private const val INTENT_CODE_MEDIA_BUTTON = 161
|
||||
private const val CALL_DIVIDE = 10
|
||||
/**
|
||||
* Central place to handle the state of the MediaSession
|
||||
*/
|
||||
class MediaSessionHandler : KoinComponent {
|
||||
|
||||
private var mediaSession: MediaSessionCompat? = null
|
||||
private var playbackState: Int? = null
|
||||
private var playbackActions: Long? = null
|
||||
private var cachedPlayingIndex: Long? = null
|
||||
|
||||
private val mediaSessionEventDistributor by inject<MediaSessionEventDistributor>()
|
||||
private val applicationContext by inject<Context>()
|
||||
|
||||
private var referenceCount: Int = 0
|
||||
private var cachedPlaylist: Iterable<MusicDirectory.Entry>? = null
|
||||
private var playbackPositionDelayCount: Int = 0
|
||||
private var cachedPosition: Long = 0
|
||||
|
||||
fun release() {
|
||||
|
||||
if (referenceCount > 0) referenceCount--
|
||||
if (referenceCount > 0) return
|
||||
|
||||
mediaSession?.isActive = false
|
||||
mediaSessionEventDistributor.releaseCachedMediaSessionToken()
|
||||
mediaSession?.release()
|
||||
mediaSession = null
|
||||
|
||||
Timber.i("MediaSessionHandler.release Media Session released")
|
||||
}
|
||||
|
||||
fun initialize() {
|
||||
|
||||
referenceCount++
|
||||
if (referenceCount > 1) return
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
val keycode = 110
|
||||
|
||||
Timber.d("MediaSessionHandler.initialize Creating Media Session")
|
||||
|
||||
mediaSession = MediaSessionCompat(applicationContext, "UltrasonicService")
|
||||
val mediaSessionToken = mediaSession!!.sessionToken
|
||||
mediaSessionEventDistributor.raiseMediaSessionTokenCreatedEvent(mediaSessionToken!!)
|
||||
|
||||
updateMediaButtonReceiver()
|
||||
|
||||
mediaSession!!.setCallback(object : MediaSessionCompat.Callback() {
|
||||
override fun onPlay() {
|
||||
super.onPlay()
|
||||
|
||||
Util.getPendingIntentForMediaAction(
|
||||
applicationContext,
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY,
|
||||
keycode
|
||||
).send()
|
||||
|
||||
Timber.v("Media Session Callback: onPlay")
|
||||
}
|
||||
|
||||
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
|
||||
super.onPlayFromMediaId(mediaId, extras)
|
||||
|
||||
Timber.d("Media Session Callback: onPlayFromMediaId %s", mediaId)
|
||||
mediaSessionEventDistributor.raisePlayFromMediaIdRequestedEvent(mediaId, extras)
|
||||
}
|
||||
|
||||
override fun onPlayFromSearch(query: String?, extras: Bundle?) {
|
||||
super.onPlayFromSearch(query, extras)
|
||||
|
||||
Timber.d("Media Session Callback: onPlayFromSearch %s", query)
|
||||
mediaSessionEventDistributor.raisePlayFromSearchRequestedEvent(query, extras)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
Util.getPendingIntentForMediaAction(
|
||||
applicationContext,
|
||||
KeyEvent.KEYCODE_MEDIA_PAUSE,
|
||||
keycode
|
||||
).send()
|
||||
Timber.v("Media Session Callback: onPause")
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
Util.getPendingIntentForMediaAction(
|
||||
applicationContext,
|
||||
KeyEvent.KEYCODE_MEDIA_STOP,
|
||||
keycode
|
||||
).send()
|
||||
Timber.v("Media Session Callback: onStop")
|
||||
}
|
||||
|
||||
override fun onSkipToNext() {
|
||||
super.onSkipToNext()
|
||||
Util.getPendingIntentForMediaAction(
|
||||
applicationContext,
|
||||
KeyEvent.KEYCODE_MEDIA_NEXT,
|
||||
keycode
|
||||
).send()
|
||||
Timber.v("Media Session Callback: onSkipToNext")
|
||||
}
|
||||
|
||||
override fun onSkipToPrevious() {
|
||||
super.onSkipToPrevious()
|
||||
Util.getPendingIntentForMediaAction(
|
||||
applicationContext,
|
||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS,
|
||||
keycode
|
||||
).send()
|
||||
Timber.v("Media Session Callback: onSkipToPrevious")
|
||||
}
|
||||
|
||||
override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean {
|
||||
// This probably won't be necessary once we implement more
|
||||
// of the modern media APIs, like the MediaController etc.
|
||||
val event = mediaButtonEvent.extras!!["android.intent.extra.KEY_EVENT"] as KeyEvent?
|
||||
mediaSessionEventDistributor.raiseMediaButtonEvent(event)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onSkipToQueueItem(id: Long) {
|
||||
super.onSkipToQueueItem(id)
|
||||
mediaSessionEventDistributor.raiseSkipToQueueItemRequestedEvent(id)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// It seems to be the best practice to set this to true for the lifetime of the session
|
||||
mediaSession!!.isActive = true
|
||||
if (cachedPlaylist != null) updateMediaSessionQueue(cachedPlaylist!!)
|
||||
Timber.i("MediaSessionHandler.initialize Media Session created")
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught", "LongMethod")
|
||||
fun updateMediaSession(
|
||||
currentPlaying: DownloadFile?,
|
||||
currentPlayingIndex: Long?,
|
||||
playerState: PlayerState
|
||||
) {
|
||||
Timber.d("Updating the MediaSession")
|
||||
|
||||
// Set Metadata
|
||||
val metadata = MediaMetadataCompat.Builder()
|
||||
if (currentPlaying != null) {
|
||||
try {
|
||||
val song = currentPlaying.song
|
||||
val cover = BitmapUtils.getAlbumArtBitmapFromDisk(
|
||||
song, Util.getMinDisplayMetric()
|
||||
)
|
||||
val duration = song.duration?.times(1000) ?: -1
|
||||
metadata.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, duration.toLong())
|
||||
metadata.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, song.artist)
|
||||
metadata.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, song.artist)
|
||||
metadata.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album)
|
||||
metadata.putString(MediaMetadataCompat.METADATA_KEY_TITLE, song.title)
|
||||
metadata.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, cover)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error setting the metadata")
|
||||
}
|
||||
}
|
||||
|
||||
// Save the metadata
|
||||
mediaSession!!.setMetadata(metadata.build())
|
||||
|
||||
playbackActions = PlaybackStateCompat.ACTION_PLAY_PAUSE or
|
||||
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
|
||||
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
|
||||
PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or
|
||||
PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH or
|
||||
PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM
|
||||
|
||||
// Map our playerState to native PlaybackState
|
||||
// TODO: Synchronize these APIs
|
||||
when (playerState) {
|
||||
PlayerState.STARTED -> {
|
||||
playbackState = PlaybackStateCompat.STATE_PLAYING
|
||||
playbackActions = playbackActions!! or
|
||||
PlaybackStateCompat.ACTION_PAUSE or
|
||||
PlaybackStateCompat.ACTION_STOP
|
||||
}
|
||||
PlayerState.COMPLETED,
|
||||
PlayerState.STOPPED -> {
|
||||
playbackState = PlaybackStateCompat.STATE_STOPPED
|
||||
cachedPosition = PLAYBACK_POSITION_UNKNOWN
|
||||
}
|
||||
PlayerState.IDLE -> {
|
||||
// IDLE state usually just means the playback is stopped
|
||||
// STATE_NONE means that there is no track to play (playlist is empty)
|
||||
playbackState = if (currentPlaying == null)
|
||||
PlaybackStateCompat.STATE_NONE
|
||||
else
|
||||
PlaybackStateCompat.STATE_STOPPED
|
||||
playbackActions = 0L
|
||||
cachedPosition = PLAYBACK_POSITION_UNKNOWN
|
||||
}
|
||||
PlayerState.PAUSED -> {
|
||||
playbackState = PlaybackStateCompat.STATE_PAUSED
|
||||
playbackActions = playbackActions!! or
|
||||
PlaybackStateCompat.ACTION_PLAY or
|
||||
PlaybackStateCompat.ACTION_STOP
|
||||
}
|
||||
else -> {
|
||||
// These are the states PREPARING, PREPARED & DOWNLOADING
|
||||
playbackState = PlaybackStateCompat.STATE_PAUSED
|
||||
}
|
||||
}
|
||||
|
||||
val playbackStateBuilder = PlaybackStateCompat.Builder()
|
||||
playbackStateBuilder.setState(playbackState!!, cachedPosition, 1.0f)
|
||||
|
||||
// Set actions
|
||||
playbackStateBuilder.setActions(playbackActions!!)
|
||||
|
||||
cachedPlayingIndex = currentPlayingIndex
|
||||
if (currentPlayingIndex != null)
|
||||
playbackStateBuilder.setActiveQueueItemId(currentPlayingIndex)
|
||||
|
||||
// Save the playback state
|
||||
mediaSession!!.setPlaybackState(playbackStateBuilder.build())
|
||||
}
|
||||
|
||||
fun updateMediaSessionQueue(playlist: Iterable<MusicDirectory.Entry>) {
|
||||
// This call is cached because Downloader may initialize earlier than the MediaSession
|
||||
cachedPlaylist = playlist
|
||||
if (mediaSession == null) return
|
||||
|
||||
mediaSession!!.setQueueTitle(applicationContext.getString(R.string.button_bar_now_playing))
|
||||
mediaSession!!.setQueue(
|
||||
playlist.mapIndexed { id, song ->
|
||||
MediaSessionCompat.QueueItem(
|
||||
Util.getMediaDescriptionForEntry(song),
|
||||
id.toLong()
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun updateMediaSessionPlaybackPosition(playbackPosition: Long) {
|
||||
|
||||
cachedPosition = playbackPosition
|
||||
if (mediaSession == null) return
|
||||
|
||||
if (playbackState == null || playbackActions == null) return
|
||||
|
||||
// Playback position is updated too frequently in the player.
|
||||
// This counter makes sure that the MediaSession is updated ~ at every second
|
||||
playbackPositionDelayCount++
|
||||
if (playbackPositionDelayCount < CALL_DIVIDE) return
|
||||
|
||||
playbackPositionDelayCount = 0
|
||||
val playbackStateBuilder = PlaybackStateCompat.Builder()
|
||||
playbackStateBuilder.setState(playbackState!!, playbackPosition, 1.0f)
|
||||
playbackStateBuilder.setActions(playbackActions!!)
|
||||
|
||||
if (cachedPlayingIndex != null)
|
||||
playbackStateBuilder.setActiveQueueItemId(cachedPlayingIndex!!)
|
||||
|
||||
mediaSession!!.setPlaybackState(playbackStateBuilder.build())
|
||||
}
|
||||
|
||||
fun updateMediaButtonReceiver() {
|
||||
if (Util.getMediaButtonsEnabled()) {
|
||||
registerMediaButtonEventReceiver()
|
||||
} else {
|
||||
unregisterMediaButtonEventReceiver()
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerMediaButtonEventReceiver() {
|
||||
val component = ComponentName(
|
||||
applicationContext.packageName,
|
||||
MediaButtonIntentReceiver::class.java.name
|
||||
)
|
||||
val mediaButtonIntent = Intent(Intent.ACTION_MEDIA_BUTTON)
|
||||
mediaButtonIntent.component = component
|
||||
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
applicationContext,
|
||||
INTENT_CODE_MEDIA_BUTTON,
|
||||
mediaButtonIntent,
|
||||
PendingIntent.FLAG_CANCEL_CURRENT
|
||||
)
|
||||
|
||||
mediaSession?.setMediaButtonReceiver(pendingIntent)
|
||||
}
|
||||
|
||||
private fun unregisterMediaButtonEventReceiver() {
|
||||
mediaSession?.setMediaButtonReceiver(null)
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFF"
|
||||
android:pathData="M9,13.75c-2.34,0 -7,1.17 -7,3.5L2,19h14v-1.75c0,-2.33 -4.66,-3.5 -7,-3.5zM4.34,17c0.84,-0.58 2.87,-1.25 4.66,-1.25s3.82,0.67 4.66,1.25L4.34,17zM9,12c1.93,0 3.5,-1.57 3.5,-3.5S10.93,5 9,5 5.5,6.57 5.5,8.5 7.07,12 9,12zM9,7c0.83,0 1.5,0.67 1.5,1.5S9.83,10 9,10s-1.5,-0.67 -1.5,-1.5S8.17,7 9,7zM16.04,13.81c1.16,0.84 1.96,1.96 1.96,3.44L18,19h4v-1.75c0,-2.02 -3.5,-3.17 -5.96,-3.44zM15,12c1.93,0 3.5,-1.57 3.5,-3.5S16.93,5 15,5c-0.54,0 -1.04,0.13 -1.5,0.35 0.63,0.89 1,1.98 1,3.15s-0.37,2.26 -1,3.15c0.46,0.22 0.96,0.35 1.5,0.35z"/>
|
||||
</vector>
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFF"
|
||||
android:pathData="M22,6h-5v8.18C16.69,14.07 16.35,14 16,14c-1.66,0 -3,1.34 -3,3s1.34,3 3,3s3,-1.34 3,-3V8h3V6zM15,6H3v2h12V6zM15,10H3v2h12V10zM11,14H3v2h8V14z"/>
|
||||
</vector>
|
|
@ -0,0 +1,3 @@
|
|||
<automotiveApp>
|
||||
<uses name="media"/>
|
||||
</automotiveApp>
|
Loading…
Reference in New Issue