Moved MediaSession handling to its own class

Fixed queue and position handling on Now Playing screen
This commit is contained in:
Nite 2021-07-13 19:25:37 +02:00
parent 83c6b76d0a
commit 56af9e4bf2
No known key found for this signature in database
GPG Key ID: 1D1AD59B1C6386C1
16 changed files with 777 additions and 694 deletions

View File

@ -61,7 +61,7 @@
<service
android:name=".service.AutoMediaBrowserService"
android:label="Ultrasonic Auto Media Player Service"
android:label="@string/common.appname"
android:exported="true">
<intent-filter>

View File

@ -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) {

View File

@ -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;
}
}
}

View File

@ -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;
}
});
}
}

View File

@ -692,16 +692,6 @@ public class Util
return Bitmap.createScaledBitmap(bitmap, size, getScaledHeight(bitmap, size), true);
}
// Trigger an update on the MediaSession. Depending on the preference it will register
// or deregister the MediaButtonReceiver.
public static void updateMediaButtonEventReceiver()
{
MediaPlayerService mediaPlayerService = MediaPlayerService.getRunningInstance();
if (mediaPlayerService != null) {
mediaPlayerService.updateMediaButtonReceiver();
}
}
public static MusicDirectory getSongsFromSearchResult(SearchResult searchResult)
{
MusicDirectory musicDirectory = new MusicDirectory();

View File

@ -5,6 +5,7 @@ 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
@ -19,4 +20,5 @@ val applicationModule = module {
single { NowPlayingEventDistributor() }
single { ThemeChangedEventDistributor() }
single { MediaSessionEventDistributor() }
single { MediaSessionHandler() }
}

View File

@ -17,12 +17,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.

View File

@ -10,6 +10,7 @@ import androidx.media.utils.MediaConstants
import org.koin.android.ext.android.inject
import org.moire.ultrasonic.util.MediaSessionEventDistributor
import org.moire.ultrasonic.util.MediaSessionEventListener
import org.moire.ultrasonic.util.MediaSessionHandler
import timber.log.Timber
@ -23,49 +24,49 @@ const val MY_MEDIA_PLAYLIST_ID = "MY_MEDIA_PLAYLIST_ID"
class AutoMediaBrowserService : MediaBrowserServiceCompat() {
private lateinit var mediaSessionEventListener: MediaSessionEventListener
private val mediaSessionEventDistributor: MediaSessionEventDistributor by inject()
private val lifecycleSupport: MediaPlayerLifecycleSupport by inject()
private val mediaSessionEventDistributor by inject<MediaSessionEventDistributor>()
private val lifecycleSupport by inject<MediaPlayerLifecycleSupport>()
private val mediaSessionHandler by inject<MediaSessionHandler>()
override fun onCreate() {
super.onCreate()
mediaSessionEventListener = object : MediaSessionEventListener {
override fun onMediaSessionTokenCreated(token: MediaSessionCompat.Token) {
Timber.i("AutoMediaBrowserService onMediaSessionTokenCreated called")
if (sessionToken == null) {
Timber.i("AutoMediaBrowserService onMediaSessionTokenCreated session token was null, set it to %s", token.toString())
sessionToken = token
}
}
override fun onPlayFromMediaIdRequested(mediaId: String?, extras: Bundle?) {
// TODO implement
Timber.i("AutoMediaBrowserService onPlayFromMediaIdRequested called")
}
override fun onPlayFromSearchRequested(query: String?, extras: Bundle?) {
// TODO implement
Timber.i("AutoMediaBrowserService onPlayFromSearchRequested called")
}
}
mediaSessionEventDistributor.subscribe(mediaSessionEventListener)
mediaSessionHandler.initialize()
val handler = Handler()
handler.postDelayed({
Timber.i("AutoMediaBrowserService starting lifecycleSupport and MediaPlayerService...")
// TODO it seems Android Auto handles autostart, but we must check that
// Ultrasonic may be started from Android Auto. This boots up the necessary components.
Timber.d("AutoMediaBrowserService starting lifecycleSupport and MediaPlayerService...")
lifecycleSupport.onCreate()
MediaPlayerService.getInstance()
}, 100)
Timber.i("AutoMediaBrowserService onCreate called")
Timber.i("AutoMediaBrowserService onCreate finished")
}
override fun onDestroy() {
super.onDestroy()
mediaSessionEventDistributor.unsubscribe(mediaSessionEventListener)
Timber.i("AutoMediaBrowserService onDestroy called")
mediaSessionHandler.release()
Timber.i("AutoMediaBrowserService onDestroy finished")
}
override fun onGetRoot(
@ -73,7 +74,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
clientUid: Int,
rootHints: Bundle?
): BrowserRoot? {
Timber.i("AutoMediaBrowserService onGetRoot called")
Timber.d("AutoMediaBrowserService onGetRoot called")
// TODO: The number of horizontal items available on the Andoid Auto screen. Check and handle.
val maximumRootChildLimit = rootHints!!.getInt(
@ -102,7 +103,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
parentId: String,
result: Result<MutableList<MediaBrowserCompat.MediaItem>>
) {
Timber.i("AutoMediaBrowserService onLoadChildren called")
Timber.d("AutoMediaBrowserService onLoadChildren called")
if (parentId == MY_MEDIA_ROOT_ID) {
return getRootItems(result)

View File

@ -142,8 +142,8 @@ class AutoMediaPlayerService: MediaBrowserServiceCompat() {
albumListModel = AlbumListModel(application)
artistListModel = ArtistListModel(application)
mediaPlayerService.onCreate()
mediaPlayerService.updateMediaSession(null, PlayerState.IDLE)
//mediaPlayerService.onCreate()
//mediaPlayerService.updateMediaSession(null, PlayerState.IDLE)
}
override fun onGetRoot(clientPackageName: String, clientUid: Int, rootHints: Bundle?): BrowserRoot? {

View File

@ -0,0 +1,105 @@
package org.moire.ultrasonic.service
import android.content.Context
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
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.
*/
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)
}
}

View File

@ -21,6 +21,8 @@ import android.os.PowerManager
import android.os.PowerManager.PARTIAL_WAKE_LOCK
import android.os.PowerManager.WakeLock
import androidx.lifecycle.MutableLiveData
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.File
import java.net.URLEncoder
import java.util.Locale
@ -32,6 +34,7 @@ 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,11 @@ 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
) {
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
@ -705,8 +709,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

View File

@ -0,0 +1,280 @@
/*
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.media.AudioManager
import android.os.Build
import android.view.KeyEvent
import kotlinx.coroutines.newFixedThreadPoolContext
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)
}
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.
*/
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()
}
}
}
}

View File

@ -8,16 +8,11 @@
package org.moire.ultrasonic.service
import android.app.*
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.support.v4.media.MediaDescriptionCompat
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
@ -34,10 +29,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,13 +55,14 @@ 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: MediaSessionEventDistributor by inject()
private val mediaSessionEventDistributor by inject<MediaSessionEventDistributor>()
private val mediaSessionHandler by inject<MediaSessionHandler>()
private var mediaSession: MediaSessionCompat? = null
var mediaSessionToken: MediaSessionCompat.Token? = 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()
@ -96,11 +93,18 @@ class MediaPlayerService : Service() {
localMediaPlayer.onNextSongRequested = Runnable { setNextPlaying() }
// TODO maybe MediaSession must be in an independent class after all...
// It seems this must be initialized in the stopped state too, e.g. for Android Auto.
// So it is best to init this early.
initMediaSessions()
updateMediaSession(null, PlayerState.IDLE)
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()
@ -121,11 +125,13 @@ class MediaPlayerService : Service() {
super.onDestroy()
instance = null
try {
mediaSessionEventDistributor.unsubscribe(mediaSessionEventListener)
mediaSessionHandler.release()
localMediaPlayer.release()
downloader.stop()
shufflePlayBuffer.onDestroy()
mediaSessionEventDistributor.ReleaseCachedMediaSessionToken()
mediaSession?.release()
mediaSession = null
} catch (ignored: Throwable) {
@ -377,7 +383,7 @@ 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(
@ -477,104 +483,6 @@ class MediaPlayerService : Service() {
}
}
fun updateMediaSession(currentPlaying: DownloadFile?, playerState: PlayerState) {
Timber.d("Updating the MediaSession")
val playbackState = PlaybackStateCompat.Builder()
// 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)
playbackState.setActiveQueueItemId(downloader.currentPlayingIndex.toLong())
} catch (e: Exception) {
Timber.e(e, "Error setting the metadata")
}
}
// Save the metadata
mediaSession!!.setMetadata(metadata.build())
// Create playback State
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 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 -> {
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
}
}
// TODO playerPosition should be updated more frequently (currently this function is called only when the playing track changes)
playbackState.setState(state, playerPosition.toLong(), 1.0f)
// Set actions
playbackState.setActions(actions)
// Save the playback state
mediaSession!!.setPlaybackState(playbackState.build())
// Set Active state
mediaSession!!.isActive = isActive
// TODO Implement Now Playing queue handling properly
mediaSession!!.setQueueTitle("Now Playing")
mediaSession!!.setQueue(downloader.downloadList.mapIndexed { id, file ->
MediaSessionCompat.QueueItem(MediaDescriptionCompat.Builder()
.setTitle(file.song.title)
.build(), id.toLong())
})
Timber.d("Setting the MediaSession to active = %s", isActive)
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@ -814,134 +722,11 @@ class MediaPlayerService : Service() {
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
mediaSessionEventDistributor.RaiseMediaSessionTokenCreatedEvent(mediaSessionToken!!)
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 onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
super.onPlayFromMediaId(mediaId, extras)
Timber.d("Media Session Callback: onPlayFromMediaId")
mediaSessionEventDistributor.RaisePlayFromMediaIdRequestedEvent(mediaId, extras)
}
override fun onPlayFromSearch(query: String?, extras: Bundle?) {
super.onPlayFromSearch(query, extras)
Timber.d("Media Session Callback: onPlayFromSearch")
mediaSessionEventDistributor.RaisePlayFromSearchRequestedEvent(query, extras)
}
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
}
override fun onSkipToQueueItem(id: Long) {
super.onSkipToQueueItem(id)
play(id.toInt())
}
}
)
}
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()

View File

@ -2,6 +2,7 @@ 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.
@ -26,24 +27,32 @@ class MediaSessionEventDistributor {
eventListenerList.remove(listener)
}
fun ReleaseCachedMediaSessionToken() {
fun releaseCachedMediaSessionToken() {
synchronized(this) {
cachedToken = null
}
}
fun RaiseMediaSessionTokenCreatedEvent(token: MediaSessionCompat.Token) {
fun raiseMediaSessionTokenCreatedEvent(token: MediaSessionCompat.Token) {
synchronized(this) {
cachedToken = token
eventListenerList.forEach { listener -> listener.onMediaSessionTokenCreated(token) }
}
}
fun RaisePlayFromMediaIdRequestedEvent(mediaId: String?, extras: Bundle?) {
fun raisePlayFromMediaIdRequestedEvent(mediaId: String?, extras: Bundle?) {
eventListenerList.forEach { listener -> listener.onPlayFromMediaIdRequested(mediaId, extras) }
}
fun RaisePlayFromSearchRequestedEvent(query: String?, extras: Bundle?) {
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) }
}
}

View File

@ -2,12 +2,15 @@ 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 onMediaSessionTokenCreated(token: MediaSessionCompat.Token) {}
fun onPlayFromMediaIdRequested(mediaId: String?, extras: Bundle?) {}
fun onPlayFromSearchRequested(query: String?, extras: Bundle?) {}
fun onSkipToQueueItemRequested(id: Long) {}
fun onMediaButtonEvent(keyEvent: KeyEvent?) {}
}

View File

@ -0,0 +1,315 @@
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.MediaDescriptionCompat
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
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
fun release() {
if (referenceCount > 0) referenceCount--
if (referenceCount > 0) return
mediaSession?.isActive = false
mediaSessionEventDistributor.releaseCachedMediaSessionToken()
mediaSession?.release()
mediaSession = null
Timber.i("MediaSessionHandler.initialize 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()
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")
mediaSessionEventDistributor.raisePlayFromMediaIdRequestedEvent(mediaId, extras)
}
override fun onPlayFromSearch(query: String?, extras: Bundle?) {
super.onPlayFromSearch(query, extras)
Timber.d("Media Session Callback: onPlayFromSearch")
mediaSessionEventDistributor.raisePlayFromSearchRequestedEvent(query, extras)
}
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?
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")
}
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
}
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
}
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!!, PLAYBACK_POSITION_UNKNOWN, 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
// TODO Implement Now Playing queue handling properly
mediaSession!!.setQueueTitle(applicationContext.getString(R.string.button_bar_now_playing))
mediaSession!!.setQueue(playlist.mapIndexed { id, song ->
MediaSessionCompat.QueueItem(
MediaDescriptionCompat.Builder()
.setTitle(song.title)
.build(), id.toLong())
})
}
fun updateMediaSessionPlaybackPosition(playbackPosition: Long) {
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 < 10) 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)
}
// TODO Copied from MediaPlayerService. Move to Utils
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)
}
}