Merge branch 'develop' into video-cleanup

This commit is contained in:
Nite 2021-08-25 14:27:05 +02:00 committed by GitHub
commit 2655a4a606
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 3378 additions and 2017 deletions

View File

@ -10,7 +10,7 @@ ext.versions = [
androidxcore : "1.5.0",
ktlint : "0.37.1",
ktlintGradle : "9.2.1",
detekt : "1.17.1",
detekt : "1.18.0",
jacoco : "0.8.7",
preferences : "1.1.1",
media : "1.3.1",

View File

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="org.moire.ultrasonic"
android:installLocation="auto">
@ -27,6 +28,14 @@
android:name=".app.UApp"
android:label="@string/common.appname"
android:usesCleartextTraffic="true">
<meta-data android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc"/>
<!--Used by Android Auto-->
<meta-data android:name="com.google.android.gms.car.notification.SmallIcon"
android:resource="@mipmap/ic_launcher" />
<activity android:name=".activity.NavigationActivity"
android:configChanges="orientation|keyboardHidden"
android:label="@string/common.appname"
@ -51,6 +60,17 @@
android:exported="false">
</service>
<service
tools:ignore="ExportedService"
android:name=".service.AutoMediaBrowserService"
android:label="@string/common.appname"
android:exported="true">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<receiver android:name=".receiver.MediaButtonIntentReceiver">
<intent-filter android:priority="2147483647">
<action android:name="android.intent.action.MEDIA_BUTTON"/>

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

@ -125,8 +125,6 @@ public final class Constants
public static final String PREFERENCES_KEY_DEFAULT_SHARE_GREETING = "sharingDefaultGreeting";
public static final String PREFERENCES_KEY_DEFAULT_SHARE_EXPIRATION = "sharingDefaultExpiration";
public static final String PREFERENCES_KEY_SHOW_ALL_SONGS_BY_ARTIST = "showAllSongsByArtist";
public static final String PREFERENCES_KEY_IMAGE_LOADER_CONCURRENCY = "imageLoaderConcurrency";
public static final String PREFERENCES_KEY_FF_IMAGE_LOADER = "ff_new_image_loader";
public static final String PREFERENCES_KEY_USE_FIVE_STAR_RATING = "use_five_star_rating";
public static final String PREFERENCES_KEY_CATEGORY_NOTIFICATIONS = "notificationsCategory";
public static final String PREFERENCES_KEY_FIRST_RUN_EXECUTED = "firstRunExecuted";

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,8 @@ import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
import org.moire.ultrasonic.util.MediaSessionEventDistributor
import org.moire.ultrasonic.util.MediaSessionHandler
import org.moire.ultrasonic.util.NowPlayingEventDistributor
import org.moire.ultrasonic.util.PermissionUtil
import org.moire.ultrasonic.util.ThemeChangedEventDistributor
@ -17,4 +19,6 @@ val applicationModule = module {
single { PermissionUtil(androidContext()) }
single { NowPlayingEventDistributor() }
single { ThemeChangedEventDistributor() }
single { MediaSessionEventDistributor() }
single { MediaSessionHandler() }
}

View File

@ -1,6 +1,5 @@
package org.moire.ultrasonic.di
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
import org.moire.ultrasonic.service.AudioFocusHandler
import org.moire.ultrasonic.service.DownloadQueueSerializer
@ -17,12 +16,12 @@ import org.moire.ultrasonic.util.ShufflePlayBuffer
*/
val mediaPlayerModule = module {
single { JukeboxMediaPlayer(get()) }
single { MediaPlayerLifecycleSupport(get(), get(), get()) }
single { DownloadQueueSerializer(androidContext()) }
single { MediaPlayerLifecycleSupport() }
single { DownloadQueueSerializer() }
single { ExternalStorageMonitor() }
single { ShufflePlayBuffer() }
single { Downloader(get(), get(), get()) }
single { LocalMediaPlayer(get(), androidContext()) }
single { LocalMediaPlayer() }
single { AudioFocusHandler(get()) }
// TODO Ideally this can be cleaned up when all circular references are removed.

View File

@ -313,7 +313,7 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon
}
repeatButton.setOnClickListener {
val repeatMode = mediaPlayerController.repeatMode?.next()
val repeatMode = mediaPlayerController.repeatMode.next()
mediaPlayerController.repeatMode = repeatMode
onDownloadListChanged()
when (repeatMode) {

View File

@ -0,0 +1,112 @@
/*
* DownloadQueueSerializer.kt
* Copyright (C) 2009-2021 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.service
import android.content.Context
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.locks.Lock
import java.util.concurrent.locks.ReentrantLock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.FileUtil
import org.moire.ultrasonic.util.MediaSessionHandler
import timber.log.Timber
/**
* This class is responsible for the serialization / deserialization
* of the DownloadQueue (playlist) to the filesystem.
* It also serializes the player state e.g. current playing number and play position.
*/
class DownloadQueueSerializer : KoinComponent {
private val context by inject<Context>()
private val mediaSessionHandler by inject<MediaSessionHandler>()
val lock: Lock = ReentrantLock()
val setup = AtomicBoolean(false)
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
fun serializeDownloadQueue(
songs: Iterable<DownloadFile>,
currentPlayingIndex: Int,
currentPlayingPosition: Int
) {
if (!setup.get()) return
appScope.launch {
if (lock.tryLock()) {
try {
serializeDownloadQueueNow(songs, currentPlayingIndex, currentPlayingPosition)
} finally {
lock.unlock()
}
}
}
}
fun serializeDownloadQueueNow(
songs: Iterable<DownloadFile>,
currentPlayingIndex: Int,
currentPlayingPosition: Int
) {
val state = State()
for (downloadFile in songs) {
state.songs.add(downloadFile.song)
}
state.currentPlayingIndex = currentPlayingIndex
state.currentPlayingPosition = currentPlayingPosition
Timber.i(
"Serialized currentPlayingIndex: %d, currentPlayingPosition: %d",
state.currentPlayingIndex,
state.currentPlayingPosition
)
FileUtil.serialize(context, state, Constants.FILENAME_DOWNLOADS_SER)
// This is called here because the queue is usually serialized after a change
mediaSessionHandler.updateMediaSessionQueue(state.songs)
}
fun deserializeDownloadQueue(afterDeserialized: Consumer<State?>) {
appScope.launch {
try {
lock.lock()
deserializeDownloadQueueNow(afterDeserialized)
setup.set(true)
} finally {
lock.unlock()
}
}
}
private fun deserializeDownloadQueueNow(afterDeserialized: Consumer<State?>) {
val state = FileUtil.deserialize<State>(
context, Constants.FILENAME_DOWNLOADS_SER
) ?: return
Timber.i(
"Deserialized currentPlayingIndex: %d, currentPlayingPosition: %d ",
state.currentPlayingIndex,
state.currentPlayingPosition
)
mediaSessionHandler.updateMediaSessionQueue(state.songs)
afterDeserialized.accept(state)
}
}

View File

@ -26,12 +26,15 @@ import java.net.URLEncoder
import java.util.Locale
import kotlin.math.abs
import kotlin.math.max
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.audiofx.EqualizerController
import org.moire.ultrasonic.audiofx.VisualizerController
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
import org.moire.ultrasonic.domain.PlayerState
import org.moire.ultrasonic.util.CancellableTask
import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.MediaSessionHandler
import org.moire.ultrasonic.util.StreamProxy
import org.moire.ultrasonic.util.Util
import timber.log.Timber
@ -39,10 +42,12 @@ import timber.log.Timber
/**
* Represents a Media Player which uses the mobile's resources for playback
*/
class LocalMediaPlayer(
private val audioFocusHandler: AudioFocusHandler,
private val context: Context
) {
@Suppress("TooManyFunctions")
class LocalMediaPlayer : KoinComponent {
private val audioFocusHandler by inject<AudioFocusHandler>()
private val context by inject<Context>()
private val mediaSessionHandler by inject<MediaSessionHandler>()
@JvmField
var onCurrentPlayingChanged: ((DownloadFile?) -> Unit?)? = null
@ -125,6 +130,10 @@ class LocalMediaPlayer(
}
fun release() {
// Calling reset() will result in changing this player's state. If we allow
// the onPlayerStateChanged callback, then the state change will cause this
// to resurrect the media session which has just been destroyed.
onPlayerStateChanged = null
reset()
try {
val i = Intent(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION)
@ -167,7 +176,7 @@ class LocalMediaPlayer(
val mainHandler = Handler(context.mainLooper)
val myRunnable = Runnable {
onPlayerStateChanged!!(playerState, currentPlaying)
onPlayerStateChanged?.invoke(playerState, currentPlaying)
}
mainHandler.post(myRunnable)
}
@ -701,8 +710,11 @@ class LocalMediaPlayer(
try {
if (playerState === PlayerState.STARTED) {
cachedPosition = mediaPlayer.currentPosition
mediaSessionHandler.updateMediaSessionPlaybackPosition(
cachedPosition.toLong()
)
}
Util.sleepQuietly(50L)
Util.sleepQuietly(100L)
} catch (e: Exception) {
Timber.w(e, "Crashed getting current position")
isRunning = false

View File

@ -247,10 +247,10 @@ class MediaPlayerController(
}
@set:Synchronized
var repeatMode: RepeatMode?
get() = Util.getRepeatMode()
var repeatMode: RepeatMode
get() = Util.repeatMode
set(repeatMode) {
Util.setRepeatMode(repeatMode)
Util.repeatMode = repeatMode
val mediaPlayerService = runningInstance
mediaPlayerService?.setNextPlaying()
}

View File

@ -0,0 +1,278 @@
/*
* MediaPlayerLifecycleSupport.kt
* Copyright (C) 2009-2021 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.media.AudioManager
import android.os.Build
import android.view.KeyEvent
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.app.UApp.Companion.applicationContext
import org.moire.ultrasonic.domain.PlayerState
import org.moire.ultrasonic.util.CacheCleaner
import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.MediaSessionEventDistributor
import org.moire.ultrasonic.util.MediaSessionEventListener
import org.moire.ultrasonic.util.Util
import timber.log.Timber
/**
* This class is responsible for handling received events for the Media Player implementation
*
* @author Sindre Mehus
*/
class MediaPlayerLifecycleSupport : KoinComponent {
private val downloadQueueSerializer by inject<DownloadQueueSerializer>()
private val mediaPlayerController by inject<MediaPlayerController>()
private val downloader by inject<Downloader>()
private val mediaSessionEventDistributor by inject<MediaSessionEventDistributor>()
private var created = false
private var headsetEventReceiver: BroadcastReceiver? = null
private lateinit var mediaSessionEventListener: MediaSessionEventListener
fun onCreate() {
onCreate(false, null)
}
private fun onCreate(autoPlay: Boolean, afterCreated: Runnable?) {
if (created) {
afterCreated?.run()
return
}
mediaSessionEventListener = object : MediaSessionEventListener {
override fun onMediaButtonEvent(keyEvent: KeyEvent?) {
if (keyEvent != null) handleKeyEvent(keyEvent)
}
}
mediaSessionEventDistributor.subscribe(mediaSessionEventListener)
registerHeadsetReceiver()
mediaPlayerController.onCreate()
if (autoPlay) mediaPlayerController.preload()
downloadQueueSerializer.deserializeDownloadQueue(object : Consumer<State?>() {
override fun accept(state: State?) {
mediaPlayerController.restore(
state!!.songs,
state.currentPlayingIndex,
state.currentPlayingPosition,
autoPlay,
false
)
// Work-around: Serialize again, as the restore() method creates a
// serialization without current playing info.
downloadQueueSerializer.serializeDownloadQueue(
downloader.downloadList,
downloader.currentPlayingIndex,
mediaPlayerController.playerPosition
)
afterCreated?.run()
}
})
CacheCleaner().clean()
created = true
Timber.i("LifecycleSupport created")
}
fun onDestroy() {
if (!created) return
downloadQueueSerializer.serializeDownloadQueueNow(
downloader.downloadList,
downloader.currentPlayingIndex,
mediaPlayerController.playerPosition
)
mediaSessionEventDistributor.unsubscribe(mediaSessionEventListener)
mediaPlayerController.clear(false)
applicationContext().unregisterReceiver(headsetEventReceiver)
mediaPlayerController.onDestroy()
created = false
Timber.i("LifecycleSupport destroyed")
}
fun receiveIntent(intent: Intent?) {
if (intent == null) return
val intentAction = intent.action
if (intentAction == null || intentAction.isEmpty()) return
Timber.i("Received intent: %s", intentAction)
if (intentAction == Constants.CMD_PROCESS_KEYCODE) {
if (intent.extras != null) {
val event = intent.extras!![Intent.EXTRA_KEY_EVENT] as KeyEvent?
event?.let { handleKeyEvent(it) }
}
} else {
handleUltrasonicIntent(intentAction)
}
}
/**
* The Headset Intent Receiver is responsible for resuming playback when a headset is inserted
* and pausing it when it is removed.
* Unfortunately this Intent can't be registered in the AndroidManifest, so it works only
* while Ultrasonic is running.
*/
private fun registerHeadsetReceiver() {
val sp = Util.getPreferences()
val context = applicationContext()
val spKey = context
.getString(R.string.settings_playback_resume_play_on_headphones_plug)
headsetEventReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val extras = intent.extras ?: return
Timber.i("Headset event for: %s", extras["name"])
val state = extras.getInt("state")
if (state == 0) {
if (!mediaPlayerController.isJukeboxEnabled) {
mediaPlayerController.pause()
}
} else if (state == 1) {
if (!mediaPlayerController.isJukeboxEnabled &&
sp.getBoolean(
spKey,
false
) && mediaPlayerController.playerState === PlayerState.PAUSED
) {
mediaPlayerController.start()
}
}
}
}
val headsetIntentFilter: IntentFilter =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
IntentFilter(AudioManager.ACTION_HEADSET_PLUG)
} else {
IntentFilter(Intent.ACTION_HEADSET_PLUG)
}
applicationContext().registerReceiver(headsetEventReceiver, headsetIntentFilter)
}
@Suppress("MagicNumber", "ComplexMethod")
private fun handleKeyEvent(event: KeyEvent) {
if (event.action != KeyEvent.ACTION_DOWN || event.repeatCount > 0) return
val keyCode: Int
val receivedKeyCode = event.keyCode
// Translate PLAY and PAUSE codes to PLAY_PAUSE to improve compatibility with old Bluetooth devices
keyCode = if (Util.getSingleButtonPlayPause() && (
receivedKeyCode == KeyEvent.KEYCODE_MEDIA_PLAY ||
receivedKeyCode == KeyEvent.KEYCODE_MEDIA_PAUSE
)
) {
Timber.i("Single button Play/Pause is set, rewriting keyCode to PLAY_PAUSE")
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE
} else receivedKeyCode
val autoStart =
keyCode == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE ||
keyCode == KeyEvent.KEYCODE_MEDIA_PLAY ||
keyCode == KeyEvent.KEYCODE_HEADSETHOOK ||
keyCode == KeyEvent.KEYCODE_MEDIA_PREVIOUS ||
keyCode == KeyEvent.KEYCODE_MEDIA_NEXT
// We can receive intents (e.g. MediaButton) when everything is stopped, so we need to start
onCreate(autoStart) {
when (keyCode) {
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE,
KeyEvent.KEYCODE_HEADSETHOOK -> mediaPlayerController.togglePlayPause()
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> mediaPlayerController.previous()
KeyEvent.KEYCODE_MEDIA_NEXT -> mediaPlayerController.next()
KeyEvent.KEYCODE_MEDIA_STOP -> mediaPlayerController.stop()
KeyEvent.KEYCODE_MEDIA_PLAY ->
if (mediaPlayerController.playerState === PlayerState.IDLE) {
mediaPlayerController.play()
} else if (mediaPlayerController.playerState !== PlayerState.STARTED) {
mediaPlayerController.start()
}
KeyEvent.KEYCODE_MEDIA_PAUSE -> mediaPlayerController.pause()
KeyEvent.KEYCODE_1 -> mediaPlayerController.setSongRating(1)
KeyEvent.KEYCODE_2 -> mediaPlayerController.setSongRating(2)
KeyEvent.KEYCODE_3 -> mediaPlayerController.setSongRating(3)
KeyEvent.KEYCODE_4 -> mediaPlayerController.setSongRating(4)
KeyEvent.KEYCODE_5 -> mediaPlayerController.setSongRating(5)
KeyEvent.KEYCODE_STAR -> mediaPlayerController.toggleSongStarred()
else -> {
}
}
}
}
/**
* This function processes the intent that could come from other applications.
*/
@Suppress("ComplexMethod")
private fun handleUltrasonicIntent(intentAction: String) {
val isRunning = created
// If Ultrasonic is not running, do nothing to stop or pause
if (
!isRunning && (
intentAction == Constants.CMD_PAUSE ||
intentAction == Constants.CMD_STOP
)
) return
val autoStart =
intentAction == Constants.CMD_PLAY ||
intentAction == Constants.CMD_RESUME_OR_PLAY ||
intentAction == Constants.CMD_TOGGLEPAUSE ||
intentAction == Constants.CMD_PREVIOUS ||
intentAction == Constants.CMD_NEXT
// We can receive intents when everything is stopped, so we need to start
onCreate(autoStart) {
when (intentAction) {
Constants.CMD_PLAY -> mediaPlayerController.play()
Constants.CMD_RESUME_OR_PLAY ->
// If Ultrasonic wasn't running, the autoStart is enough to resume,
// no need to call anything
if (isRunning) mediaPlayerController.resumeOrPlay()
Constants.CMD_NEXT -> mediaPlayerController.next()
Constants.CMD_PREVIOUS -> mediaPlayerController.previous()
Constants.CMD_TOGGLEPAUSE -> mediaPlayerController.togglePlayPause()
Constants.CMD_STOP -> {
// TODO: There is a stop() function, shouldn't we use that?
mediaPlayerController.pause()
mediaPlayerController.seekTo(0)
}
Constants.CMD_PAUSE -> mediaPlayerController.pause()
}
}
}
}

View File

@ -12,17 +12,15 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import android.view.KeyEvent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import kotlin.collections.ArrayList
import org.koin.android.ext.android.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.activity.NavigationActivity
@ -35,9 +33,11 @@ import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X1
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X2
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X3
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X4
import org.moire.ultrasonic.receiver.MediaButtonIntentReceiver
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.MediaSessionEventDistributor
import org.moire.ultrasonic.util.MediaSessionEventListener
import org.moire.ultrasonic.util.MediaSessionHandler
import org.moire.ultrasonic.util.NowPlayingEventDistributor
import org.moire.ultrasonic.util.ShufflePlayBuffer
import org.moire.ultrasonic.util.SimpleServiceBinder
@ -59,15 +59,17 @@ class MediaPlayerService : Service() {
private val downloader by inject<Downloader>()
private val localMediaPlayer by inject<LocalMediaPlayer>()
private val nowPlayingEventDistributor by inject<NowPlayingEventDistributor>()
private val mediaPlayerLifecycleSupport by inject<MediaPlayerLifecycleSupport>()
private val mediaSessionEventDistributor by inject<MediaSessionEventDistributor>()
private val mediaSessionHandler by inject<MediaSessionHandler>()
private var mediaSession: MediaSessionCompat? = null
private var mediaSessionToken: MediaSessionCompat.Token? = null
private var isInForeground = false
private var notificationBuilder: NotificationCompat.Builder? = null
private lateinit var mediaSessionEventListener: MediaSessionEventListener
private val repeatMode: RepeatMode
get() = Util.getRepeatMode()
get() = Util.repeatMode
override fun onBind(intent: Intent): IBinder {
return binder
@ -95,6 +97,19 @@ class MediaPlayerService : Service() {
localMediaPlayer.onNextSongRequested = Runnable { setNextPlaying() }
mediaSessionEventListener = object : MediaSessionEventListener {
override fun onMediaSessionTokenCreated(token: MediaSessionCompat.Token) {
mediaSessionToken = token
}
override fun onSkipToQueueItemRequested(id: Long) {
play(id.toInt())
}
}
mediaSessionEventDistributor.subscribe(mediaSessionEventListener)
mediaSessionHandler.initialize()
// Create Notification Channel
createNotificationChannel()
@ -114,9 +129,13 @@ class MediaPlayerService : Service() {
super.onDestroy()
instance = null
try {
mediaSessionEventDistributor.unsubscribe(mediaSessionEventListener)
mediaSessionHandler.release()
localMediaPlayer.release()
downloader.stop()
shufflePlayBuffer.onDestroy()
mediaSession?.release()
mediaSession = null
} catch (ignored: Throwable) {
@ -368,7 +387,11 @@ class MediaPlayerService : Service() {
val context = this@MediaPlayerService
// Notify MediaSession
updateMediaSession(currentPlaying, playerState)
mediaSessionHandler.updateMediaSession(
currentPlaying,
downloader.currentPlayingIndex.toLong(),
playerState
)
if (playerState === PlayerState.PAUSED) {
downloadQueueSerializer.serializeDownloadQueue(
@ -468,90 +491,6 @@ class MediaPlayerService : Service() {
}
}
private fun updateMediaSession(currentPlaying: DownloadFile?, playerState: PlayerState) {
Timber.d("Updating the MediaSession")
if (mediaSession == null) initMediaSessions()
// Set Metadata
val metadata = MediaMetadataCompat.Builder()
if (currentPlaying != null) {
try {
val song = currentPlaying.song
val cover = BitmapUtils.getAlbumArtBitmapFromDisk(
song, Util.getMinDisplayMetric()
)
metadata.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, -1L)
metadata.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, song.artist)
metadata.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, song.artist)
metadata.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album)
metadata.putString(MediaMetadataCompat.METADATA_KEY_TITLE, song.title)
metadata.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, cover)
} catch (e: Exception) {
Timber.e(e, "Error setting the metadata")
}
}
// Save the metadata
mediaSession!!.setMetadata(metadata.build())
// Create playback State
val playbackState = PlaybackStateCompat.Builder()
val state: Int
val isActive: Boolean
var actions: Long = PlaybackStateCompat.ACTION_PLAY_PAUSE or
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
// Map our playerState to native PlaybackState
// TODO: Synchronize these APIs
when (playerState) {
PlayerState.STARTED -> {
state = PlaybackStateCompat.STATE_PLAYING
isActive = true
actions = actions or
PlaybackStateCompat.ACTION_PAUSE or
PlaybackStateCompat.ACTION_STOP
}
PlayerState.COMPLETED,
PlayerState.STOPPED -> {
isActive = false
state = PlaybackStateCompat.STATE_STOPPED
}
PlayerState.IDLE -> {
isActive = false
state = PlaybackStateCompat.STATE_NONE
actions = 0L
}
PlayerState.PAUSED -> {
isActive = true
state = PlaybackStateCompat.STATE_PAUSED
actions = actions or
PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_STOP
}
else -> {
// These are the states PREPARING, PREPARED & DOWNLOADING
isActive = true
state = PlaybackStateCompat.STATE_PAUSED
}
}
playbackState.setState(state, PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN, 1.0f)
// Set actions
playbackState.setActions(actions)
// Save the playback state
mediaSession!!.setPlaybackState(playbackState.build())
// Set Active state
mediaSession!!.isActive = isActive
Timber.d("Setting the MediaSession to active = %s", isActive)
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@ -604,7 +543,11 @@ class MediaPlayerService : Service() {
// Init
val context = applicationContext
val song = currentPlaying?.song
val stopIntent = getPendingIntentForMediaAction(context, KeyEvent.KEYCODE_MEDIA_STOP, 100)
val stopIntent = Util.getPendingIntentForMediaAction(
context,
KeyEvent.KEYCODE_MEDIA_STOP,
100
)
// We should use a single notification builder, otherwise the notification may not be updated
if (notificationBuilder == null) {
@ -723,7 +666,7 @@ class MediaPlayerService : Service() {
else -> return null
}
val pendingIntent = getPendingIntentForMediaAction(context, keycode, requestCode)
val pendingIntent = Util.getPendingIntentForMediaAction(context, keycode, requestCode)
return NotificationCompat.Action.Builder(icon, label, pendingIntent).build()
}
@ -734,7 +677,7 @@ class MediaPlayerService : Service() {
): NotificationCompat.Action {
val isPlaying = playerState === PlayerState.STARTED
val keycode = KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE
val pendingIntent = getPendingIntentForMediaAction(context, keycode, requestCode)
val pendingIntent = Util.getPendingIntentForMediaAction(context, keycode, requestCode)
val label: String
val icon: Int
@ -767,7 +710,7 @@ class MediaPlayerService : Service() {
icon = R.drawable.ic_star_hollow_dark
}
val pendingIntent = getPendingIntentForMediaAction(context, keyCode, requestCode)
val pendingIntent = Util.getPendingIntentForMediaAction(context, keyCode, requestCode)
return NotificationCompat.Action.Builder(icon, label, pendingIntent).build()
}
@ -779,126 +722,11 @@ class MediaPlayerService : Service() {
return PendingIntent.getActivity(this, 0, intent, flags)
}
private fun getPendingIntentForMediaAction(
context: Context,
keycode: Int,
requestCode: Int
): PendingIntent {
val intent = Intent(Constants.CMD_PROCESS_KEYCODE)
val flags = PendingIntent.FLAG_UPDATE_CURRENT
intent.setPackage(context.packageName)
intent.putExtra(Intent.EXTRA_KEY_EVENT, KeyEvent(KeyEvent.ACTION_DOWN, keycode))
return PendingIntent.getBroadcast(context, requestCode, intent, flags)
}
private fun initMediaSessions() {
@Suppress("MagicNumber")
val keycode = 110
Timber.w("Creating media session")
mediaSession = MediaSessionCompat(applicationContext, "UltrasonicService")
mediaSessionToken = mediaSession!!.sessionToken
updateMediaButtonReceiver()
mediaSession!!.setCallback(object : MediaSessionCompat.Callback() {
override fun onPlay() {
super.onPlay()
getPendingIntentForMediaAction(
applicationContext,
KeyEvent.KEYCODE_MEDIA_PLAY,
keycode
).send()
Timber.v("Media Session Callback: onPlay")
}
override fun onPause() {
super.onPause()
getPendingIntentForMediaAction(
applicationContext,
KeyEvent.KEYCODE_MEDIA_PAUSE,
keycode
).send()
Timber.v("Media Session Callback: onPause")
}
override fun onStop() {
super.onStop()
getPendingIntentForMediaAction(
applicationContext,
KeyEvent.KEYCODE_MEDIA_STOP,
keycode
).send()
Timber.v("Media Session Callback: onStop")
}
override fun onSkipToNext() {
super.onSkipToNext()
getPendingIntentForMediaAction(
applicationContext,
KeyEvent.KEYCODE_MEDIA_NEXT,
keycode
).send()
Timber.v("Media Session Callback: onSkipToNext")
}
override fun onSkipToPrevious() {
super.onSkipToPrevious()
getPendingIntentForMediaAction(
applicationContext,
KeyEvent.KEYCODE_MEDIA_PREVIOUS,
keycode
).send()
Timber.v("Media Session Callback: onSkipToPrevious")
}
override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean {
// This probably won't be necessary once we implement more
// of the modern media APIs, like the MediaController etc.
val event = mediaButtonEvent.extras!!["android.intent.extra.KEY_EVENT"] as KeyEvent?
mediaPlayerLifecycleSupport.handleKeyEvent(event)
return true
}
}
)
}
fun updateMediaButtonReceiver() {
if (Util.getMediaButtonsEnabled()) {
registerMediaButtonEventReceiver()
} else {
unregisterMediaButtonEventReceiver()
}
}
private fun registerMediaButtonEventReceiver() {
val component = ComponentName(packageName, MediaButtonIntentReceiver::class.java.name)
val mediaButtonIntent = Intent(Intent.ACTION_MEDIA_BUTTON)
mediaButtonIntent.component = component
val pendingIntent = PendingIntent.getBroadcast(
this,
INTENT_CODE_MEDIA_BUTTON,
mediaButtonIntent,
PendingIntent.FLAG_CANCEL_CURRENT
)
mediaSession?.setMediaButtonReceiver(pendingIntent)
}
private fun unregisterMediaButtonEventReceiver() {
mediaSession?.setMediaButtonReceiver(null)
}
@Suppress("MagicNumber")
companion object {
private const val NOTIFICATION_CHANNEL_ID = "org.moire.ultrasonic"
private const val NOTIFICATION_CHANNEL_NAME = "Ultrasonic background service"
private const val NOTIFICATION_ID = 3033
private const val INTENT_CODE_MEDIA_BUTTON = 161
private var instance: MediaPlayerService? = null
private val instanceLock = Any()

View File

@ -40,13 +40,13 @@ class ShareHandler(val context: Context) {
swipe: SwipeRefreshLayout?,
cancellationToken: CancellationToken
) {
val askForDetails = Util.getShouldAskForShareDetails()
val askForDetails = Util.shouldAskForShareDetails
val shareDetails = ShareDetails()
shareDetails.Entries = entries
if (askForDetails) {
showDialog(fragment, shareDetails, swipe, cancellationToken)
} else {
shareDetails.Description = Util.getDefaultShareDescription()
shareDetails.Description = Util.defaultShareDescription
shareDetails.Expiration = TimeSpan.getCurrentTime().add(
Util.getDefaultShareExpirationInMillis(context)
).totalMilliseconds
@ -133,16 +133,16 @@ class ShareHandler(val context: Context) {
}
shareDetails.Description = shareDescription!!.text.toString()
if (hideDialogCheckBox!!.isChecked) {
Util.setShouldAskForShareDetails(false)
Util.shouldAskForShareDetails = false
}
if (saveAsDefaultsCheckBox!!.isChecked) {
val timeSpanType: String = timeSpanPicker!!.timeSpanType
val timeSpanAmount: Int = timeSpanPicker!!.timeSpanAmount
Util.setDefaultShareExpiration(
Util.defaultShareExpiration =
if (!noExpirationCheckBox!!.isChecked && timeSpanAmount > 0)
String.format("%d:%s", timeSpanAmount, timeSpanType) else ""
)
Util.setDefaultShareDescription(shareDetails.Description)
Util.defaultShareDescription = shareDetails.Description
}
share(fragment, shareDetails, swipe, cancellationToken)
}
@ -157,8 +157,8 @@ class ShareHandler(val context: Context) {
b ->
timeSpanPicker!!.isEnabled = !b
}
val defaultDescription = Util.getDefaultShareDescription()
val timeSpan = Util.getDefaultShareExpiration()
val defaultDescription = Util.defaultShareDescription
val timeSpan = Util.defaultShareExpiration
val split = pattern.split(timeSpan)
if (split.size == 2) {
val timeSpanAmount = split[0].toInt()

View File

@ -0,0 +1,68 @@
/*
* MediaSessionEventDistributor.kt
* Copyright (C) 2009-2021 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.util
import android.os.Bundle
import android.support.v4.media.session.MediaSessionCompat
import android.view.KeyEvent
/**
* This class distributes MediaSession related events to its subscribers.
* It is a primitive implementation of a pub-sub event bus
*/
class MediaSessionEventDistributor {
var eventListenerList: MutableList<MediaSessionEventListener> =
listOf<MediaSessionEventListener>().toMutableList()
var cachedToken: MediaSessionCompat.Token? = null
fun subscribe(listener: MediaSessionEventListener) {
eventListenerList.add(listener)
synchronized(this) {
if (cachedToken != null)
listener.onMediaSessionTokenCreated(cachedToken!!)
}
}
fun unsubscribe(listener: MediaSessionEventListener) {
eventListenerList.remove(listener)
}
fun releaseCachedMediaSessionToken() {
synchronized(this) {
cachedToken = null
}
}
fun raiseMediaSessionTokenCreatedEvent(token: MediaSessionCompat.Token) {
synchronized(this) {
cachedToken = token
eventListenerList.forEach { listener -> listener.onMediaSessionTokenCreated(token) }
}
}
fun raisePlayFromMediaIdRequestedEvent(mediaId: String?, extras: Bundle?) {
eventListenerList.forEach {
listener ->
listener.onPlayFromMediaIdRequested(mediaId, extras)
}
}
fun raisePlayFromSearchRequestedEvent(query: String?, extras: Bundle?) {
eventListenerList.forEach { listener -> listener.onPlayFromSearchRequested(query, extras) }
}
fun raiseSkipToQueueItemRequestedEvent(id: Long) {
eventListenerList.forEach { listener -> listener.onSkipToQueueItemRequested(id) }
}
fun raiseMediaButtonEvent(keyEvent: KeyEvent?) {
eventListenerList.forEach { listener -> listener.onMediaButtonEvent(keyEvent) }
}
}

View File

@ -0,0 +1,23 @@
/*
* MediaSessionEventListener.kt
* Copyright (C) 2009-2021 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.util
import android.os.Bundle
import android.support.v4.media.session.MediaSessionCompat
import android.view.KeyEvent
/**
* Callback interface for MediaSession related event subscribers
*/
interface MediaSessionEventListener {
fun onMediaSessionTokenCreated(token: MediaSessionCompat.Token) {}
fun onPlayFromMediaIdRequested(mediaId: String?, extras: Bundle?) {}
fun onPlayFromSearchRequested(query: String?, extras: Bundle?) {}
fun onSkipToQueueItemRequested(id: Long) {}
fun onMediaButtonEvent(keyEvent: KeyEvent?) {}
}

View File

@ -0,0 +1,323 @@
/*
* MediaSessionHandler.kt
* Copyright (C) 2009-2021 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.util
import android.app.PendingIntent
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import android.support.v4.media.session.PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN
import android.view.KeyEvent
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.domain.PlayerState
import org.moire.ultrasonic.imageloader.BitmapUtils
import org.moire.ultrasonic.receiver.MediaButtonIntentReceiver
import org.moire.ultrasonic.service.DownloadFile
import timber.log.Timber
private const val INTENT_CODE_MEDIA_BUTTON = 161
private const val CALL_DIVIDE = 10
/**
* Central place to handle the state of the MediaSession
*/
class MediaSessionHandler : KoinComponent {
private var mediaSession: MediaSessionCompat? = null
private var playbackState: Int? = null
private var playbackActions: Long? = null
private var cachedPlayingIndex: Long? = null
private val mediaSessionEventDistributor by inject<MediaSessionEventDistributor>()
private val applicationContext by inject<Context>()
private var referenceCount: Int = 0
private var cachedPlaylist: Iterable<MusicDirectory.Entry>? = null
private var playbackPositionDelayCount: Int = 0
private var cachedPosition: Long = 0
fun release() {
if (referenceCount > 0) referenceCount--
if (referenceCount > 0) return
mediaSession?.isActive = false
mediaSessionEventDistributor.releaseCachedMediaSessionToken()
mediaSession?.release()
mediaSession = null
Timber.i("MediaSessionHandler.release Media Session released")
}
fun initialize() {
referenceCount++
if (referenceCount > 1) return
@Suppress("MagicNumber")
val keycode = 110
Timber.d("MediaSessionHandler.initialize Creating Media Session")
mediaSession = MediaSessionCompat(applicationContext, "UltrasonicService")
val mediaSessionToken = mediaSession!!.sessionToken
mediaSessionEventDistributor.raiseMediaSessionTokenCreatedEvent(mediaSessionToken!!)
updateMediaButtonReceiver()
mediaSession!!.setCallback(object : MediaSessionCompat.Callback() {
override fun onPlay() {
super.onPlay()
Util.getPendingIntentForMediaAction(
applicationContext,
KeyEvent.KEYCODE_MEDIA_PLAY,
keycode
).send()
Timber.v("Media Session Callback: onPlay")
}
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
super.onPlayFromMediaId(mediaId, extras)
Timber.d("Media Session Callback: onPlayFromMediaId %s", mediaId)
mediaSessionEventDistributor.raisePlayFromMediaIdRequestedEvent(mediaId, extras)
}
override fun onPlayFromSearch(query: String?, extras: Bundle?) {
super.onPlayFromSearch(query, extras)
Timber.d("Media Session Callback: onPlayFromSearch %s", query)
mediaSessionEventDistributor.raisePlayFromSearchRequestedEvent(query, extras)
}
override fun onPause() {
super.onPause()
Util.getPendingIntentForMediaAction(
applicationContext,
KeyEvent.KEYCODE_MEDIA_PAUSE,
keycode
).send()
Timber.v("Media Session Callback: onPause")
}
override fun onStop() {
super.onStop()
Util.getPendingIntentForMediaAction(
applicationContext,
KeyEvent.KEYCODE_MEDIA_STOP,
keycode
).send()
Timber.v("Media Session Callback: onStop")
}
override fun onSkipToNext() {
super.onSkipToNext()
Util.getPendingIntentForMediaAction(
applicationContext,
KeyEvent.KEYCODE_MEDIA_NEXT,
keycode
).send()
Timber.v("Media Session Callback: onSkipToNext")
}
override fun onSkipToPrevious() {
super.onSkipToPrevious()
Util.getPendingIntentForMediaAction(
applicationContext,
KeyEvent.KEYCODE_MEDIA_PREVIOUS,
keycode
).send()
Timber.v("Media Session Callback: onSkipToPrevious")
}
override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean {
// This probably won't be necessary once we implement more
// of the modern media APIs, like the MediaController etc.
val event = mediaButtonEvent.extras!!["android.intent.extra.KEY_EVENT"] as KeyEvent?
mediaSessionEventDistributor.raiseMediaButtonEvent(event)
return true
}
override fun onSkipToQueueItem(id: Long) {
super.onSkipToQueueItem(id)
mediaSessionEventDistributor.raiseSkipToQueueItemRequestedEvent(id)
}
}
)
// It seems to be the best practice to set this to true for the lifetime of the session
mediaSession!!.isActive = true
if (cachedPlaylist != null) updateMediaSessionQueue(cachedPlaylist!!)
Timber.i("MediaSessionHandler.initialize Media Session created")
}
@Suppress("TooGenericExceptionCaught", "LongMethod")
fun updateMediaSession(
currentPlaying: DownloadFile?,
currentPlayingIndex: Long?,
playerState: PlayerState
) {
Timber.d("Updating the MediaSession")
// Set Metadata
val metadata = MediaMetadataCompat.Builder()
if (currentPlaying != null) {
try {
val song = currentPlaying.song
val cover = BitmapUtils.getAlbumArtBitmapFromDisk(
song, Util.getMinDisplayMetric()
)
val duration = song.duration?.times(1000) ?: -1
metadata.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, duration.toLong())
metadata.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, song.artist)
metadata.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, song.artist)
metadata.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album)
metadata.putString(MediaMetadataCompat.METADATA_KEY_TITLE, song.title)
metadata.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, cover)
} catch (e: Exception) {
Timber.e(e, "Error setting the metadata")
}
}
// Save the metadata
mediaSession!!.setMetadata(metadata.build())
playbackActions = PlaybackStateCompat.ACTION_PLAY_PAUSE or
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or
PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH or
PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM
// Map our playerState to native PlaybackState
// TODO: Synchronize these APIs
when (playerState) {
PlayerState.STARTED -> {
playbackState = PlaybackStateCompat.STATE_PLAYING
playbackActions = playbackActions!! or
PlaybackStateCompat.ACTION_PAUSE or
PlaybackStateCompat.ACTION_STOP
}
PlayerState.COMPLETED,
PlayerState.STOPPED -> {
playbackState = PlaybackStateCompat.STATE_STOPPED
cachedPosition = PLAYBACK_POSITION_UNKNOWN
}
PlayerState.IDLE -> {
// IDLE state usually just means the playback is stopped
// STATE_NONE means that there is no track to play (playlist is empty)
playbackState = if (currentPlaying == null)
PlaybackStateCompat.STATE_NONE
else
PlaybackStateCompat.STATE_STOPPED
playbackActions = 0L
cachedPosition = PLAYBACK_POSITION_UNKNOWN
}
PlayerState.PAUSED -> {
playbackState = PlaybackStateCompat.STATE_PAUSED
playbackActions = playbackActions!! or
PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_STOP
}
else -> {
// These are the states PREPARING, PREPARED & DOWNLOADING
playbackState = PlaybackStateCompat.STATE_PAUSED
}
}
val playbackStateBuilder = PlaybackStateCompat.Builder()
playbackStateBuilder.setState(playbackState!!, cachedPosition, 1.0f)
// Set actions
playbackStateBuilder.setActions(playbackActions!!)
cachedPlayingIndex = currentPlayingIndex
if (currentPlayingIndex != null)
playbackStateBuilder.setActiveQueueItemId(currentPlayingIndex)
// Save the playback state
mediaSession!!.setPlaybackState(playbackStateBuilder.build())
}
fun updateMediaSessionQueue(playlist: Iterable<MusicDirectory.Entry>) {
// This call is cached because Downloader may initialize earlier than the MediaSession
cachedPlaylist = playlist
if (mediaSession == null) return
mediaSession!!.setQueueTitle(applicationContext.getString(R.string.button_bar_now_playing))
mediaSession!!.setQueue(
playlist.mapIndexed { id, song ->
MediaSessionCompat.QueueItem(
Util.getMediaDescriptionForEntry(song),
id.toLong()
)
}
)
}
fun updateMediaSessionPlaybackPosition(playbackPosition: Long) {
cachedPosition = playbackPosition
if (mediaSession == null) return
if (playbackState == null || playbackActions == null) return
// Playback position is updated too frequently in the player.
// This counter makes sure that the MediaSession is updated ~ at every second
playbackPositionDelayCount++
if (playbackPositionDelayCount < CALL_DIVIDE) return
playbackPositionDelayCount = 0
val playbackStateBuilder = PlaybackStateCompat.Builder()
playbackStateBuilder.setState(playbackState!!, playbackPosition, 1.0f)
playbackStateBuilder.setActions(playbackActions!!)
if (cachedPlayingIndex != null)
playbackStateBuilder.setActiveQueueItemId(cachedPlayingIndex!!)
mediaSession!!.setPlaybackState(playbackStateBuilder.build())
}
fun updateMediaButtonReceiver() {
if (Util.getMediaButtonsEnabled()) {
registerMediaButtonEventReceiver()
} else {
unregisterMediaButtonEventReceiver()
}
}
private fun registerMediaButtonEventReceiver() {
val component = ComponentName(
applicationContext.packageName,
MediaButtonIntentReceiver::class.java.name
)
val mediaButtonIntent = Intent(Intent.ACTION_MEDIA_BUTTON)
mediaButtonIntent.component = component
val pendingIntent = PendingIntent.getBroadcast(
applicationContext,
INTENT_CODE_MEDIA_BUTTON,
mediaButtonIntent,
PendingIntent.FLAG_CANCEL_CURRENT
)
mediaSession?.setMediaButtonReceiver(pendingIntent)
}
private fun unregisterMediaButtonEventReceiver() {
mediaSession?.setMediaButtonReceiver(null)
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFF"
android:pathData="M9,13.75c-2.34,0 -7,1.17 -7,3.5L2,19h14v-1.75c0,-2.33 -4.66,-3.5 -7,-3.5zM4.34,17c0.84,-0.58 2.87,-1.25 4.66,-1.25s3.82,0.67 4.66,1.25L4.34,17zM9,12c1.93,0 3.5,-1.57 3.5,-3.5S10.93,5 9,5 5.5,6.57 5.5,8.5 7.07,12 9,12zM9,7c0.83,0 1.5,0.67 1.5,1.5S9.83,10 9,10s-1.5,-0.67 -1.5,-1.5S8.17,7 9,7zM16.04,13.81c1.16,0.84 1.96,1.96 1.96,3.44L18,19h4v-1.75c0,-2.02 -3.5,-3.17 -5.96,-3.44zM15,12c1.93,0 3.5,-1.57 3.5,-3.5S16.93,5 15,5c-0.54,0 -1.04,0.13 -1.5,0.35 0.63,0.89 1,1.98 1,3.15s-0.37,2.26 -1,3.15c0.46,0.22 0.96,0.35 1.5,0.35z"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFF"
android:pathData="M22,6h-5v8.18C16.69,14.07 16.35,14 16,14c-1.66,0 -3,1.34 -3,3s1.34,3 3,3s3,-1.34 3,-3V8h3V6zM15,6H3v2h12V6zM15,10H3v2h12V10zM11,14H3v2h8V14z"/>
</vector>

View File

@ -0,0 +1,3 @@
<automotiveApp>
<uses name="media"/>
</automotiveApp>