diff --git a/ultrasonic/src/main/AndroidManifest.xml b/ultrasonic/src/main/AndroidManifest.xml index f9abed6b..08a2d5aa 100644 --- a/ultrasonic/src/main/AndroidManifest.xml +++ b/ultrasonic/src/main/AndroidManifest.xml @@ -61,7 +61,7 @@ diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SettingsFragment.java b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SettingsFragment.java index 5a6cfdd8..3125f1d0 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SettingsFragment.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SettingsFragment.java @@ -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 mediaPlayerControllerLazy = inject(MediaPlayerController.class); private final Lazy permissionUtil = inject(PermissionUtil.class); private final Lazy themeChangedEventDistributor = inject(ThemeChangedEventDistributor.class); + private final Lazy 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) { diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/service/DownloadQueueSerializer.java b/ultrasonic/src/main/java/org/moire/ultrasonic/service/DownloadQueueSerializer.java deleted file mode 100644 index 53134733..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/service/DownloadQueueSerializer.java +++ /dev/null @@ -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 songs, int currentPlayingIndex, int currentPlayingPosition) - { - if (!setup.get()) - { - return; - } - - new SerializeTask().execute(songs, currentPlayingIndex, currentPlayingPosition); - } - - public void serializeDownloadQueueNow(Iterable 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 afterDeserialized) - { - new DeserializeTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, afterDeserialized); - } - - public void deserializeDownloadQueueNow(Consumer 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 - { - @Override - protected Void doInBackground(Object... params) - { - if (lock.tryLock()) - { - try - { - Thread.currentThread().setName("SerializeTask"); - serializeDownloadQueueNow((Iterable)params[0], (int)params[1], (int)params[2]); - } - finally - { - lock.unlock(); - } - } - return null; - } - } - - private class DeserializeTask extends AsyncTask - { - @Override - protected Void doInBackground(Object... params) - { - try - { - Thread.currentThread().setName("DeserializeTask"); - lock.lock(); - deserializeDownloadQueueNow((Consumer)params[0]); - setup.set(true); - } - finally - { - lock.unlock(); - } - - return null; - } - } -} diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.java b/ultrasonic/src/main/java/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.java deleted file mode 100644 index 8713c7e7..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.java +++ /dev/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 . - - 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() { - @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; - } - }); - } -} \ No newline at end of file diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/util/Util.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/Util.java index e93d7cc4..7d60601a 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/Util.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/util/Util.java @@ -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(); diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt index 47b08cde..e44e0774 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt @@ -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() } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt index 6f7a751d..2e9810c4 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt @@ -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. diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt index 6afcb29a..1a9f0534 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt @@ -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() + private val lifecycleSupport by inject() + private val mediaSessionHandler by inject() 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> ) { - Timber.i("AutoMediaBrowserService onLoadChildren called") + Timber.d("AutoMediaBrowserService onLoadChildren called") if (parentId == MY_MEDIA_ROOT_ID) { return getRootItems(result) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaPlayerService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaPlayerService.kt index 2eb7fca5..a69cf32a 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaPlayerService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaPlayerService.kt @@ -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? { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadQueueSerializer.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadQueueSerializer.kt new file mode 100644 index 00000000..b88a47a0 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadQueueSerializer.kt @@ -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() + private val mediaSessionHandler by inject() + + val lock: Lock = ReentrantLock() + val setup = AtomicBoolean(false) + + private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + fun serializeDownloadQueue( + songs: Iterable, + 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, + 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) { + + appScope.launch { + try { + lock.lock() + deserializeDownloadQueueNow(afterDeserialized) + setup.set(true) + } finally { + lock.unlock() + } + } + } + + private fun deserializeDownloadQueueNow(afterDeserialized: Consumer) { + + val state = FileUtil.deserialize( + context, Constants.FILENAME_DOWNLOADS_SER + ) ?: return + + Timber.i( + "Deserialized currentPlayingIndex: %d, currentPlayingPosition: %d ", + state.currentPlayingIndex, + state.currentPlayingPosition + ) + + mediaSessionHandler.updateMediaSessionQueue(state.songs) + afterDeserialized.accept(state) + } +} \ No newline at end of file diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt index 005967b9..9b5efa05 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt @@ -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() + private val context by inject() + private val mediaSessionHandler by inject() @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 diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt new file mode 100644 index 00000000..2917d6f4 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt @@ -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 . + + 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() + private val mediaPlayerController by inject() + private val downloader by inject() + private val mediaSessionEventDistributor by inject() + + 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() { + 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() + } + } + } +} \ No newline at end of file diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt index 493591fc..04b3ba4e 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt @@ -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() private val localMediaPlayer by inject() private val nowPlayingEventDistributor by inject() - private val mediaPlayerLifecycleSupport by inject() - private val mediaSessionEventDistributor: MediaSessionEventDistributor by inject() + private val mediaSessionEventDistributor by inject() + private val mediaSessionHandler by inject() 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() diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionTokenCreatedEventDistributor.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionEventDistributor.kt similarity index 70% rename from ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionTokenCreatedEventDistributor.kt rename to ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionEventDistributor.kt index f90470a4..7313ef85 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionTokenCreatedEventDistributor.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionEventDistributor.kt @@ -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) } + } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionTokenCreatedEventListener.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionEventListener.kt similarity index 73% rename from ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionTokenCreatedEventListener.kt rename to ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionEventListener.kt index 1b1c922d..f67eb16e 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionTokenCreatedEventListener.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionEventListener.kt @@ -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?) {} } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt new file mode 100644 index 00000000..24f42e88 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt @@ -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() + private val applicationContext by inject() + + private var referenceCount: Int = 0 + private var cachedPlaylist: Iterable? = 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) + { + // 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) + } +} \ No newline at end of file