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", androidxcore : "1.5.0",
ktlint : "0.37.1", ktlint : "0.37.1",
ktlintGradle : "9.2.1", ktlintGradle : "9.2.1",
detekt : "1.17.1", detekt : "1.18.0",
jacoco : "0.8.7", jacoco : "0.8.7",
preferences : "1.1.1", preferences : "1.1.1",
media : "1.3.1", media : "1.3.1",

View File

@ -1,7 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="org.moire.ultrasonic" xmlns:tools="http://schemas.android.com/tools"
android:installLocation="auto"> package="org.moire.ultrasonic"
android:installLocation="auto">
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
@ -27,6 +28,14 @@
android:name=".app.UApp" android:name=".app.UApp"
android:label="@string/common.appname" android:label="@string/common.appname"
android:usesCleartextTraffic="true"> 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" <activity android:name=".activity.NavigationActivity"
android:configChanges="orientation|keyboardHidden" android:configChanges="orientation|keyboardHidden"
android:label="@string/common.appname" android:label="@string/common.appname"
@ -51,6 +60,17 @@
android:exported="false"> android:exported="false">
</service> </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"> <receiver android:name=".receiver.MediaButtonIntentReceiver">
<intent-filter android:priority="2147483647"> <intent-filter android:priority="2147483647">
<action android:name="android.intent.action.MEDIA_BUTTON"/> <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.service.MediaPlayerController;
import org.moire.ultrasonic.util.Constants; import org.moire.ultrasonic.util.Constants;
import org.moire.ultrasonic.util.FileUtil; import org.moire.ultrasonic.util.FileUtil;
import org.moire.ultrasonic.util.MediaSessionHandler;
import org.moire.ultrasonic.util.PermissionUtil; import org.moire.ultrasonic.util.PermissionUtil;
import org.moire.ultrasonic.util.ThemeChangedEventDistributor; import org.moire.ultrasonic.util.ThemeChangedEventDistributor;
import org.moire.ultrasonic.util.TimeSpanPreference; 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<MediaPlayerController> mediaPlayerControllerLazy = inject(MediaPlayerController.class);
private final Lazy<PermissionUtil> permissionUtil = inject(PermissionUtil.class); private final Lazy<PermissionUtil> permissionUtil = inject(PermissionUtil.class);
private final Lazy<ThemeChangedEventDistributor> themeChangedEventDistributor = inject(ThemeChangedEventDistributor.class); private final Lazy<ThemeChangedEventDistributor> themeChangedEventDistributor = inject(ThemeChangedEventDistributor.class);
private final Lazy<MediaSessionHandler> mediaSessionHandler = inject(MediaSessionHandler.class);
@Override @Override
public void onCreate(@Nullable Bundle savedInstanceState) { public void onCreate(@Nullable Bundle savedInstanceState) {
@ -468,7 +470,7 @@ public class SettingsFragment extends PreferenceFragmentCompat
private void setMediaButtonsEnabled(boolean enabled) { private void setMediaButtonsEnabled(boolean enabled) {
lockScreenEnabled.setEnabled(enabled); lockScreenEnabled.setEnabled(enabled);
Util.updateMediaButtonEventReceiver(); mediaSessionHandler.getValue().updateMediaButtonReceiver();
} }
private void setBluetoothPreferences(boolean enabled) { 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_GREETING = "sharingDefaultGreeting";
public static final String PREFERENCES_KEY_DEFAULT_SHARE_EXPIRATION = "sharingDefaultExpiration"; 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_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_USE_FIVE_STAR_RATING = "use_five_star_rating";
public static final String PREFERENCES_KEY_CATEGORY_NOTIFICATIONS = "notificationsCategory"; public static final String PREFERENCES_KEY_CATEGORY_NOTIFICATIONS = "notificationsCategory";
public static final String PREFERENCES_KEY_FIRST_RUN_EXECUTED = "firstRunExecuted"; 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.koin.dsl.module
import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.subsonic.ImageLoaderProvider 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.NowPlayingEventDistributor
import org.moire.ultrasonic.util.PermissionUtil import org.moire.ultrasonic.util.PermissionUtil
import org.moire.ultrasonic.util.ThemeChangedEventDistributor import org.moire.ultrasonic.util.ThemeChangedEventDistributor
@ -17,4 +19,6 @@ val applicationModule = module {
single { PermissionUtil(androidContext()) } single { PermissionUtil(androidContext()) }
single { NowPlayingEventDistributor() } single { NowPlayingEventDistributor() }
single { ThemeChangedEventDistributor() } single { ThemeChangedEventDistributor() }
single { MediaSessionEventDistributor() }
single { MediaSessionHandler() }
} }

View File

@ -1,6 +1,5 @@
package org.moire.ultrasonic.di package org.moire.ultrasonic.di
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module import org.koin.dsl.module
import org.moire.ultrasonic.service.AudioFocusHandler import org.moire.ultrasonic.service.AudioFocusHandler
import org.moire.ultrasonic.service.DownloadQueueSerializer import org.moire.ultrasonic.service.DownloadQueueSerializer
@ -17,12 +16,12 @@ import org.moire.ultrasonic.util.ShufflePlayBuffer
*/ */
val mediaPlayerModule = module { val mediaPlayerModule = module {
single { JukeboxMediaPlayer(get()) } single { JukeboxMediaPlayer(get()) }
single { MediaPlayerLifecycleSupport(get(), get(), get()) } single { MediaPlayerLifecycleSupport() }
single { DownloadQueueSerializer(androidContext()) } single { DownloadQueueSerializer() }
single { ExternalStorageMonitor() } single { ExternalStorageMonitor() }
single { ShufflePlayBuffer() } single { ShufflePlayBuffer() }
single { Downloader(get(), get(), get()) } single { Downloader(get(), get(), get()) }
single { LocalMediaPlayer(get(), androidContext()) } single { LocalMediaPlayer() }
single { AudioFocusHandler(get()) } single { AudioFocusHandler(get()) }
// TODO Ideally this can be cleaned up when all circular references are removed. // 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 { repeatButton.setOnClickListener {
val repeatMode = mediaPlayerController.repeatMode?.next() val repeatMode = mediaPlayerController.repeatMode.next()
mediaPlayerController.repeatMode = repeatMode mediaPlayerController.repeatMode = repeatMode
onDownloadListChanged() onDownloadListChanged()
when (repeatMode) { 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 java.util.Locale
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.max 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.EqualizerController
import org.moire.ultrasonic.audiofx.VisualizerController import org.moire.ultrasonic.audiofx.VisualizerController
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
import org.moire.ultrasonic.domain.PlayerState import org.moire.ultrasonic.domain.PlayerState
import org.moire.ultrasonic.util.CancellableTask import org.moire.ultrasonic.util.CancellableTask
import org.moire.ultrasonic.util.Constants import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.MediaSessionHandler
import org.moire.ultrasonic.util.StreamProxy import org.moire.ultrasonic.util.StreamProxy
import org.moire.ultrasonic.util.Util import org.moire.ultrasonic.util.Util
import timber.log.Timber import timber.log.Timber
@ -39,10 +42,12 @@ import timber.log.Timber
/** /**
* Represents a Media Player which uses the mobile's resources for playback * Represents a Media Player which uses the mobile's resources for playback
*/ */
class LocalMediaPlayer( @Suppress("TooManyFunctions")
private val audioFocusHandler: AudioFocusHandler, class LocalMediaPlayer : KoinComponent {
private val context: Context
) { private val audioFocusHandler by inject<AudioFocusHandler>()
private val context by inject<Context>()
private val mediaSessionHandler by inject<MediaSessionHandler>()
@JvmField @JvmField
var onCurrentPlayingChanged: ((DownloadFile?) -> Unit?)? = null var onCurrentPlayingChanged: ((DownloadFile?) -> Unit?)? = null
@ -125,6 +130,10 @@ class LocalMediaPlayer(
} }
fun release() { 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() reset()
try { try {
val i = Intent(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION) val i = Intent(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION)
@ -167,7 +176,7 @@ class LocalMediaPlayer(
val mainHandler = Handler(context.mainLooper) val mainHandler = Handler(context.mainLooper)
val myRunnable = Runnable { val myRunnable = Runnable {
onPlayerStateChanged!!(playerState, currentPlaying) onPlayerStateChanged?.invoke(playerState, currentPlaying)
} }
mainHandler.post(myRunnable) mainHandler.post(myRunnable)
} }
@ -701,8 +710,11 @@ class LocalMediaPlayer(
try { try {
if (playerState === PlayerState.STARTED) { if (playerState === PlayerState.STARTED) {
cachedPosition = mediaPlayer.currentPosition cachedPosition = mediaPlayer.currentPosition
mediaSessionHandler.updateMediaSessionPlaybackPosition(
cachedPosition.toLong()
)
} }
Util.sleepQuietly(50L) Util.sleepQuietly(100L)
} catch (e: Exception) { } catch (e: Exception) {
Timber.w(e, "Crashed getting current position") Timber.w(e, "Crashed getting current position")
isRunning = false isRunning = false

View File

@ -247,10 +247,10 @@ class MediaPlayerController(
} }
@set:Synchronized @set:Synchronized
var repeatMode: RepeatMode? var repeatMode: RepeatMode
get() = Util.getRepeatMode() get() = Util.repeatMode
set(repeatMode) { set(repeatMode) {
Util.setRepeatMode(repeatMode) Util.repeatMode = repeatMode
val mediaPlayerService = runningInstance val mediaPlayerService = runningInstance
mediaPlayerService?.setNextPlaying() 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.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import android.view.KeyEvent import android.view.KeyEvent
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import kotlin.collections.ArrayList
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import org.moire.ultrasonic.R import org.moire.ultrasonic.R
import org.moire.ultrasonic.activity.NavigationActivity 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.UltrasonicAppWidgetProvider4X2
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X3 import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X3
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X4 import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X4
import org.moire.ultrasonic.receiver.MediaButtonIntentReceiver
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import org.moire.ultrasonic.util.Constants 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.NowPlayingEventDistributor
import org.moire.ultrasonic.util.ShufflePlayBuffer import org.moire.ultrasonic.util.ShufflePlayBuffer
import org.moire.ultrasonic.util.SimpleServiceBinder import org.moire.ultrasonic.util.SimpleServiceBinder
@ -59,15 +59,17 @@ class MediaPlayerService : Service() {
private val downloader by inject<Downloader>() private val downloader by inject<Downloader>()
private val localMediaPlayer by inject<LocalMediaPlayer>() private val localMediaPlayer by inject<LocalMediaPlayer>()
private val nowPlayingEventDistributor by inject<NowPlayingEventDistributor>() 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 mediaSession: MediaSessionCompat? = null
private var mediaSessionToken: MediaSessionCompat.Token? = null private var mediaSessionToken: MediaSessionCompat.Token? = null
private var isInForeground = false private var isInForeground = false
private var notificationBuilder: NotificationCompat.Builder? = null private var notificationBuilder: NotificationCompat.Builder? = null
private lateinit var mediaSessionEventListener: MediaSessionEventListener
private val repeatMode: RepeatMode private val repeatMode: RepeatMode
get() = Util.getRepeatMode() get() = Util.repeatMode
override fun onBind(intent: Intent): IBinder { override fun onBind(intent: Intent): IBinder {
return binder return binder
@ -95,6 +97,19 @@ class MediaPlayerService : Service() {
localMediaPlayer.onNextSongRequested = Runnable { setNextPlaying() } 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 // Create Notification Channel
createNotificationChannel() createNotificationChannel()
@ -114,9 +129,13 @@ class MediaPlayerService : Service() {
super.onDestroy() super.onDestroy()
instance = null instance = null
try { try {
mediaSessionEventDistributor.unsubscribe(mediaSessionEventListener)
mediaSessionHandler.release()
localMediaPlayer.release() localMediaPlayer.release()
downloader.stop() downloader.stop()
shufflePlayBuffer.onDestroy() shufflePlayBuffer.onDestroy()
mediaSession?.release() mediaSession?.release()
mediaSession = null mediaSession = null
} catch (ignored: Throwable) { } catch (ignored: Throwable) {
@ -368,7 +387,11 @@ class MediaPlayerService : Service() {
val context = this@MediaPlayerService val context = this@MediaPlayerService
// Notify MediaSession // Notify MediaSession
updateMediaSession(currentPlaying, playerState) mediaSessionHandler.updateMediaSession(
currentPlaying,
downloader.currentPlayingIndex.toLong(),
playerState
)
if (playerState === PlayerState.PAUSED) { if (playerState === PlayerState.PAUSED) {
downloadQueueSerializer.serializeDownloadQueue( 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() { private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@ -604,7 +543,11 @@ class MediaPlayerService : Service() {
// Init // Init
val context = applicationContext val context = applicationContext
val song = currentPlaying?.song 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 // We should use a single notification builder, otherwise the notification may not be updated
if (notificationBuilder == null) { if (notificationBuilder == null) {
@ -723,7 +666,7 @@ class MediaPlayerService : Service() {
else -> return null else -> return null
} }
val pendingIntent = getPendingIntentForMediaAction(context, keycode, requestCode) val pendingIntent = Util.getPendingIntentForMediaAction(context, keycode, requestCode)
return NotificationCompat.Action.Builder(icon, label, pendingIntent).build() return NotificationCompat.Action.Builder(icon, label, pendingIntent).build()
} }
@ -734,7 +677,7 @@ class MediaPlayerService : Service() {
): NotificationCompat.Action { ): NotificationCompat.Action {
val isPlaying = playerState === PlayerState.STARTED val isPlaying = playerState === PlayerState.STARTED
val keycode = KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE val keycode = KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE
val pendingIntent = getPendingIntentForMediaAction(context, keycode, requestCode) val pendingIntent = Util.getPendingIntentForMediaAction(context, keycode, requestCode)
val label: String val label: String
val icon: Int val icon: Int
@ -767,7 +710,7 @@ class MediaPlayerService : Service() {
icon = R.drawable.ic_star_hollow_dark 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() return NotificationCompat.Action.Builder(icon, label, pendingIntent).build()
} }
@ -779,126 +722,11 @@ class MediaPlayerService : Service() {
return PendingIntent.getActivity(this, 0, intent, flags) 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") @Suppress("MagicNumber")
companion object { companion object {
private const val NOTIFICATION_CHANNEL_ID = "org.moire.ultrasonic" private const val NOTIFICATION_CHANNEL_ID = "org.moire.ultrasonic"
private const val NOTIFICATION_CHANNEL_NAME = "Ultrasonic background service" private const val NOTIFICATION_CHANNEL_NAME = "Ultrasonic background service"
private const val NOTIFICATION_ID = 3033 private const val NOTIFICATION_ID = 3033
private const val INTENT_CODE_MEDIA_BUTTON = 161
private var instance: MediaPlayerService? = null private var instance: MediaPlayerService? = null
private val instanceLock = Any() private val instanceLock = Any()

View File

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