diff --git a/dependencies.gradle b/dependencies.gradle index 1381055f..e2918767 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -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", diff --git a/ultrasonic/src/main/AndroidManifest.xml b/ultrasonic/src/main/AndroidManifest.xml index 7dcd1c7c..e80ca5f7 100644 --- a/ultrasonic/src/main/AndroidManifest.xml +++ b/ultrasonic/src/main/AndroidManifest.xml @@ -1,7 +1,8 @@ + 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"> + + + + + + + + + + + + + diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SettingsFragment.java b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SettingsFragment.java index 5a6cfdd8..3125f1d0 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SettingsFragment.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SettingsFragment.java @@ -33,6 +33,7 @@ import org.moire.ultrasonic.service.Consumer; import org.moire.ultrasonic.service.MediaPlayerController; import org.moire.ultrasonic.util.Constants; import org.moire.ultrasonic.util.FileUtil; +import org.moire.ultrasonic.util.MediaSessionHandler; import org.moire.ultrasonic.util.PermissionUtil; import org.moire.ultrasonic.util.ThemeChangedEventDistributor; import org.moire.ultrasonic.util.TimeSpanPreference; @@ -89,6 +90,7 @@ public class SettingsFragment extends PreferenceFragmentCompat private final Lazy mediaPlayerControllerLazy = inject(MediaPlayerController.class); private final Lazy permissionUtil = inject(PermissionUtil.class); private final Lazy themeChangedEventDistributor = inject(ThemeChangedEventDistributor.class); + private final Lazy mediaSessionHandler = inject(MediaSessionHandler.class); @Override public void onCreate(@Nullable Bundle savedInstanceState) { @@ -468,7 +470,7 @@ public class SettingsFragment extends PreferenceFragmentCompat private void setMediaButtonsEnabled(boolean enabled) { lockScreenEnabled.setEnabled(enabled); - Util.updateMediaButtonEventReceiver(); + mediaSessionHandler.getValue().updateMediaButtonReceiver(); } private void setBluetoothPreferences(boolean enabled) { diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/service/DownloadQueueSerializer.java b/ultrasonic/src/main/java/org/moire/ultrasonic/service/DownloadQueueSerializer.java deleted file mode 100644 index 53134733..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/service/DownloadQueueSerializer.java +++ /dev/null @@ -1,108 +0,0 @@ -package org.moire.ultrasonic.service; - -import android.content.Context; -import android.os.AsyncTask; -import timber.log.Timber; - -import org.moire.ultrasonic.util.Constants; -import org.moire.ultrasonic.util.FileUtil; - -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.locks.Lock; -import java.util.concurrent.locks.ReentrantLock; - -/** - * This class is responsible for the serialization / deserialization - * of the DownloadQueue (playlist) to the filesystem. - * It also serializes the player state e.g. current playing number and play position. - */ -public class DownloadQueueSerializer -{ - public final Lock lock = new ReentrantLock(); - public final AtomicBoolean setup = new AtomicBoolean(false); - private Context context; - - public DownloadQueueSerializer(Context context) - { - this.context = context; - } - - public void serializeDownloadQueue(Iterable songs, int currentPlayingIndex, int currentPlayingPosition) - { - if (!setup.get()) - { - return; - } - - new SerializeTask().execute(songs, currentPlayingIndex, currentPlayingPosition); - } - - public void serializeDownloadQueueNow(Iterable songs, int currentPlayingIndex, int currentPlayingPosition) - { - State state = new State(); - for (DownloadFile downloadFile : songs) - { - state.songs.add(downloadFile.getSong()); - } - state.currentPlayingIndex = currentPlayingIndex; - state.currentPlayingPosition = currentPlayingPosition; - - Timber.i("Serialized currentPlayingIndex: %d, currentPlayingPosition: %d", state.currentPlayingIndex, state.currentPlayingPosition); - FileUtil.serialize(context, state, Constants.FILENAME_DOWNLOADS_SER); - } - - public void deserializeDownloadQueue(Consumer afterDeserialized) - { - new DeserializeTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, afterDeserialized); - } - - public void deserializeDownloadQueueNow(Consumer afterDeserialized) - { - State state = FileUtil.deserialize(context, Constants.FILENAME_DOWNLOADS_SER); - if (state == null) return; - Timber.i("Deserialized currentPlayingIndex: " + state.currentPlayingIndex + ", currentPlayingPosition: " + state.currentPlayingPosition); - afterDeserialized.accept(state); - } - - private class SerializeTask extends AsyncTask - { - @Override - protected Void doInBackground(Object... params) - { - if (lock.tryLock()) - { - try - { - Thread.currentThread().setName("SerializeTask"); - serializeDownloadQueueNow((Iterable)params[0], (int)params[1], (int)params[2]); - } - finally - { - lock.unlock(); - } - } - return null; - } - } - - private class DeserializeTask extends AsyncTask - { - @Override - protected Void doInBackground(Object... params) - { - try - { - Thread.currentThread().setName("DeserializeTask"); - lock.lock(); - deserializeDownloadQueueNow((Consumer)params[0]); - setup.set(true); - } - finally - { - lock.unlock(); - } - - return null; - } - } -} diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.java b/ultrasonic/src/main/java/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.java deleted file mode 100644 index 8713c7e7..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.java +++ /dev/null @@ -1,308 +0,0 @@ -/* - This file is part of Subsonic. - - Subsonic is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - Subsonic is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with Subsonic. If not, see . - - Copyright 2009 (C) Sindre Mehus - */ -package org.moire.ultrasonic.service; - -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.SharedPreferences; -import android.media.AudioManager; -import android.os.Build; -import android.os.Bundle; -import timber.log.Timber; -import android.view.KeyEvent; - -import org.moire.ultrasonic.R; -import org.moire.ultrasonic.app.UApp; -import org.moire.ultrasonic.domain.PlayerState; -import org.moire.ultrasonic.util.CacheCleaner; -import org.moire.ultrasonic.util.Constants; -import org.moire.ultrasonic.util.Util; - -/** - * This class is responsible for handling received events for the Media Player implementation - * - * @author Sindre Mehus - */ -public class MediaPlayerLifecycleSupport -{ - private boolean created = false; - private final DownloadQueueSerializer downloadQueueSerializer; // From DI - private final MediaPlayerController mediaPlayerController; // From DI - private final Downloader downloader; // From DI - - private BroadcastReceiver headsetEventReceiver; - - public MediaPlayerLifecycleSupport(DownloadQueueSerializer downloadQueueSerializer, - final MediaPlayerController mediaPlayerController, final Downloader downloader) - { - this.downloadQueueSerializer = downloadQueueSerializer; - this.mediaPlayerController = mediaPlayerController; - this.downloader = downloader; - - Timber.i("LifecycleSupport constructed"); - } - - public void onCreate() - { - onCreate(false, null); - } - - private void onCreate(final boolean autoPlay, final Runnable afterCreated) - { - if (created) - { - if (afterCreated != null) afterCreated.run(); - return; - } - - registerHeadsetReceiver(); - - mediaPlayerController.onCreate(); - if (autoPlay) mediaPlayerController.preload(); - - this.downloadQueueSerializer.deserializeDownloadQueue(new Consumer() { - @Override - public void accept(State state) { - mediaPlayerController.restore(state.songs, state.currentPlayingIndex, state.currentPlayingPosition, autoPlay, false); - - // Work-around: Serialize again, as the restore() method creates a serialization without current playing info. - MediaPlayerLifecycleSupport.this.downloadQueueSerializer.serializeDownloadQueue(downloader.downloadList, - downloader.getCurrentPlayingIndex(), mediaPlayerController.getPlayerPosition()); - - if (afterCreated != null) afterCreated.run(); - } - }); - - new CacheCleaner().clean(); - created = true; - Timber.i("LifecycleSupport created"); - } - - public void onDestroy() - { - if (!created) return; - downloadQueueSerializer.serializeDownloadQueueNow(downloader.downloadList, - downloader.getCurrentPlayingIndex(), mediaPlayerController.getPlayerPosition()); - mediaPlayerController.clear(false); - UApp.Companion.applicationContext().unregisterReceiver(headsetEventReceiver); - mediaPlayerController.onDestroy(); - created = false; - Timber.i("LifecycleSupport destroyed"); - } - - public void receiveIntent(Intent intent) - { - if (intent == null) return; - String intentAction = intent.getAction(); - if (intentAction == null || intentAction.isEmpty()) return; - - Timber.i("Received intent: %s", intentAction); - - if (intentAction.equals(Constants.CMD_PROCESS_KEYCODE)) { - if (intent.getExtras() != null) { - KeyEvent event = (KeyEvent) intent.getExtras().get(Intent.EXTRA_KEY_EVENT); - if (event != null) { - handleKeyEvent(event); - } - } - } - else - { - handleUltrasonicIntent(intentAction); - } - } - - /** - * The Headset Intent Receiver is responsible for resuming playback when a headset is inserted - * and pausing it when it is removed. - * Unfortunately this Intent can't be registered in the AndroidManifest, so it works only - * while Ultrasonic is running. - */ - private void registerHeadsetReceiver() { - final SharedPreferences sp = Util.getPreferences(); - final Context context = UApp.Companion.applicationContext(); - final String spKey = context - .getString(R.string.settings_playback_resume_play_on_headphones_plug); - - headsetEventReceiver = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - final Bundle extras = intent.getExtras(); - - if (extras == null) { - return; - } - - Timber.i("Headset event for: %s", extras.get("name")); - final int state = extras.getInt("state"); - if (state == 0) { - if (!mediaPlayerController.isJukeboxEnabled()) { - mediaPlayerController.pause(); - } - } else if (state == 1) { - if (!mediaPlayerController.isJukeboxEnabled() && - sp.getBoolean(spKey, false) && - mediaPlayerController.getPlayerState() == PlayerState.PAUSED) { - mediaPlayerController.start(); - } - } - } - }; - - - IntentFilter headsetIntentFilter; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) - { - headsetIntentFilter = new IntentFilter(AudioManager.ACTION_HEADSET_PLUG); - } - else - { - headsetIntentFilter = new IntentFilter(Intent.ACTION_HEADSET_PLUG); - } - UApp.Companion.applicationContext().registerReceiver(headsetEventReceiver, headsetIntentFilter); - } - - public void handleKeyEvent(KeyEvent event) - { - if (event.getAction() != KeyEvent.ACTION_DOWN || event.getRepeatCount() > 0) - { - return; - } - - final int keyCode; - int receivedKeyCode = event.getKeyCode(); - // Translate PLAY and PAUSE codes to PLAY_PAUSE to improve compatibility with old Bluetooth devices - if (Util.getSingleButtonPlayPause() && - (receivedKeyCode == KeyEvent.KEYCODE_MEDIA_PLAY || - receivedKeyCode == KeyEvent.KEYCODE_MEDIA_PAUSE)) { - Timber.i("Single button Play/Pause is set, rewriting keyCode to PLAY_PAUSE"); - keyCode = KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE; - } - else keyCode = receivedKeyCode; - - boolean autoStart = (keyCode == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE || - keyCode == KeyEvent.KEYCODE_MEDIA_PLAY || - keyCode == KeyEvent.KEYCODE_HEADSETHOOK || - keyCode == KeyEvent.KEYCODE_MEDIA_PREVIOUS || - keyCode == KeyEvent.KEYCODE_MEDIA_NEXT); - - // We can receive intents (e.g. MediaButton) when everything is stopped, so we need to start - onCreate(autoStart, () -> { - switch (keyCode) - { - case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE: - case KeyEvent.KEYCODE_HEADSETHOOK: - mediaPlayerController.togglePlayPause(); - break; - case KeyEvent.KEYCODE_MEDIA_PREVIOUS: - mediaPlayerController.previous(); - break; - case KeyEvent.KEYCODE_MEDIA_NEXT: - mediaPlayerController.next(); - break; - case KeyEvent.KEYCODE_MEDIA_STOP: - mediaPlayerController.stop(); - break; - case KeyEvent.KEYCODE_MEDIA_PLAY: - if (mediaPlayerController.getPlayerState() == PlayerState.IDLE) - { - mediaPlayerController.play(); - } - else if (mediaPlayerController.getPlayerState() != PlayerState.STARTED) - { - mediaPlayerController.start(); - } - break; - case KeyEvent.KEYCODE_MEDIA_PAUSE: - mediaPlayerController.pause(); - break; - case KeyEvent.KEYCODE_1: - mediaPlayerController.setSongRating(1); - break; - case KeyEvent.KEYCODE_2: - mediaPlayerController.setSongRating(2); - break; - case KeyEvent.KEYCODE_3: - mediaPlayerController.setSongRating(3); - break; - case KeyEvent.KEYCODE_4: - mediaPlayerController.setSongRating(4); - break; - case KeyEvent.KEYCODE_5: - mediaPlayerController.setSongRating(5); - break; - case KeyEvent.KEYCODE_STAR: - mediaPlayerController.toggleSongStarred(); - break; - default: - break; - } - }); - } - - /** - * This function processes the intent that could come from other applications. - */ - private void handleUltrasonicIntent(final String intentAction) - { - final boolean isRunning = created; - // If Ultrasonic is not running, do nothing to stop or pause - if (!isRunning && (intentAction.equals(Constants.CMD_PAUSE) || - intentAction.equals(Constants.CMD_STOP))) return; - - boolean autoStart = (intentAction.equals(Constants.CMD_PLAY) || - intentAction.equals(Constants.CMD_RESUME_OR_PLAY) || - intentAction.equals(Constants.CMD_TOGGLEPAUSE) || - intentAction.equals(Constants.CMD_PREVIOUS) || - intentAction.equals(Constants.CMD_NEXT)); - - // We can receive intents when everything is stopped, so we need to start - onCreate(autoStart, () -> { - switch(intentAction) - { - case Constants.CMD_PLAY: - mediaPlayerController.play(); - break; - case Constants.CMD_RESUME_OR_PLAY: - // If Ultrasonic wasn't running, the autoStart is enough to resume, no need to call anything - if (isRunning) mediaPlayerController.resumeOrPlay(); - break; - case Constants.CMD_NEXT: - mediaPlayerController.next(); - break; - case Constants.CMD_PREVIOUS: - mediaPlayerController.previous(); - break; - case Constants.CMD_TOGGLEPAUSE: - mediaPlayerController.togglePlayPause(); - break; - case Constants.CMD_STOP: - // TODO: There is a stop() function, shouldn't we use that? - mediaPlayerController.pause(); - mediaPlayerController.seekTo(0); - break; - case Constants.CMD_PAUSE: - mediaPlayerController.pause(); - break; - } - }); - } -} \ No newline at end of file diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/util/Constants.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/Constants.java index 880564d8..28404977 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/Constants.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/util/Constants.java @@ -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"; diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/util/Util.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/Util.java deleted file mode 100644 index e93d7cc4..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/Util.java +++ /dev/null @@ -1,1363 +0,0 @@ -/* - This file is part of Subsonic. - - Subsonic is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - Subsonic is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with Subsonic. If not, see . - - Copyright 2009 (C) Sindre Mehus - */ -package org.moire.ultrasonic.util; - -import android.annotation.SuppressLint; -import android.app.Activity; -import android.app.AlertDialog; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.PackageManager; -import android.content.res.Resources; -import android.content.res.TypedArray; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.graphics.Canvas; -import android.graphics.drawable.BitmapDrawable; -import android.graphics.drawable.Drawable; -import android.net.ConnectivityManager; -import android.net.NetworkInfo; -import android.net.Uri; -import android.net.wifi.WifiManager; -import android.os.Build; -import android.os.Environment; -import android.os.Parcelable; -import android.util.DisplayMetrics; -import android.util.TypedValue; -import android.view.Gravity; -import android.view.View; -import android.view.inputmethod.InputMethodManager; -import android.widget.Toast; - -import androidx.preference.PreferenceManager; - -import org.moire.ultrasonic.R; -import org.moire.ultrasonic.app.UApp; -import org.moire.ultrasonic.data.ActiveServerProvider; -import org.moire.ultrasonic.domain.Bookmark; -import org.moire.ultrasonic.domain.MusicDirectory; -import org.moire.ultrasonic.domain.MusicDirectory.Entry; -import org.moire.ultrasonic.domain.PlayerState; -import org.moire.ultrasonic.domain.RepeatMode; -import org.moire.ultrasonic.domain.SearchResult; -import org.moire.ultrasonic.service.DownloadFile; -import org.moire.ultrasonic.service.MediaPlayerService; - -import java.io.ByteArrayOutputStream; -import java.io.Closeable; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.io.UnsupportedEncodingException; -import java.security.MessageDigest; -import java.text.DecimalFormat; -import java.util.Locale; -import java.util.concurrent.TimeUnit; -import java.util.regex.Pattern; - -import timber.log.Timber; - -/** - * @author Sindre Mehus - * @version $Id$ - */ -public class Util -{ - private static final DecimalFormat GIGA_BYTE_FORMAT = new DecimalFormat("0.00 GB"); - private static final DecimalFormat MEGA_BYTE_FORMAT = new DecimalFormat("0.00 MB"); - private static final DecimalFormat KILO_BYTE_FORMAT = new DecimalFormat("0 KB"); - private static final Pattern PATTERN = Pattern.compile(":"); - - private static DecimalFormat GIGA_BYTE_LOCALIZED_FORMAT; - private static DecimalFormat MEGA_BYTE_LOCALIZED_FORMAT; - private static DecimalFormat KILO_BYTE_LOCALIZED_FORMAT; - private static DecimalFormat BYTE_LOCALIZED_FORMAT; - - public static final String EVENT_META_CHANGED = "org.moire.ultrasonic.EVENT_META_CHANGED"; - public static final String EVENT_PLAYSTATE_CHANGED = "org.moire.ultrasonic.EVENT_PLAYSTATE_CHANGED"; - - public static final String CM_AVRCP_PLAYSTATE_CHANGED = "com.android.music.playstatechanged"; - public static final String CM_AVRCP_METADATA_CHANGED = "com.android.music.metachanged"; - - // Used by hexEncode() - private static final char[] HEX_DIGITS = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'}; - private static Toast toast; - - private static Entry currentSong; - - private Util() - { - } - - // Retrieves an instance of the application Context - public static Context appContext() { - return UApp.Companion.applicationContext(); - } - - public static boolean isScreenLitOnDownload() - { - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_SCREEN_LIT_ON_DOWNLOAD, false); - } - - public static RepeatMode getRepeatMode() - { - SharedPreferences preferences = getPreferences(); - return RepeatMode.valueOf(preferences.getString(Constants.PREFERENCES_KEY_REPEAT_MODE, RepeatMode.OFF.name())); - } - - public static void setRepeatMode(RepeatMode repeatMode) - { - SharedPreferences preferences = getPreferences(); - SharedPreferences.Editor editor = preferences.edit(); - editor.putString(Constants.PREFERENCES_KEY_REPEAT_MODE, repeatMode.name()); - editor.apply(); - } - - public static boolean isNotificationEnabled() - { - // After API26 foreground services must be used for music playback, and they must have a notification - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) return true; - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_SHOW_NOTIFICATION, false); - } - - public static boolean isNotificationAlwaysEnabled() - { - // After API26 foreground services must be used for music playback, and they must have a notification - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) return true; - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_ALWAYS_SHOW_NOTIFICATION, false); - } - - public static boolean isLockScreenEnabled() - { - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_SHOW_LOCK_SCREEN_CONTROLS, false); - } - - public static String getTheme() - { - SharedPreferences preferences = getPreferences(); - return preferences.getString(Constants.PREFERENCES_KEY_THEME, Constants.PREFERENCES_KEY_THEME_DARK); - } - - public static void applyTheme(Context context) - { - String theme = Util.getTheme(); - - if (Constants.PREFERENCES_KEY_THEME_DARK.equalsIgnoreCase(theme) || "fullscreen".equalsIgnoreCase(theme)) - { - context.setTheme(R.style.UltrasonicTheme); - } - else if (Constants.PREFERENCES_KEY_THEME_BLACK.equalsIgnoreCase(theme)) - { - context.setTheme(R.style.UltrasonicTheme_Black); - } - else if (Constants.PREFERENCES_KEY_THEME_LIGHT.equalsIgnoreCase(theme) || "fullscreenlight".equalsIgnoreCase(theme)) - { - context.setTheme(R.style.UltrasonicTheme_Light); - } - } - - public static ConnectivityManager getConnectivityManager() { - Context context = appContext(); - return (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); - } - - public static int getMaxBitRate() - { - ConnectivityManager manager = getConnectivityManager(); - NetworkInfo networkInfo = manager.getActiveNetworkInfo(); - - if (networkInfo == null) - { - return 0; - } - - boolean wifi = networkInfo.getType() == ConnectivityManager.TYPE_WIFI; - SharedPreferences preferences = getPreferences(); - return Integer.parseInt(preferences.getString(wifi ? Constants.PREFERENCES_KEY_MAX_BITRATE_WIFI : Constants.PREFERENCES_KEY_MAX_BITRATE_MOBILE, "0")); - } - - public static int getPreloadCount() - { - SharedPreferences preferences = getPreferences(); - int preloadCount = Integer.parseInt(preferences.getString(Constants.PREFERENCES_KEY_PRELOAD_COUNT, "-1")); - return preloadCount == -1 ? Integer.MAX_VALUE : preloadCount; - } - - public static int getCacheSizeMB() - { - SharedPreferences preferences = getPreferences(); - int cacheSize = Integer.parseInt(preferences.getString(Constants.PREFERENCES_KEY_CACHE_SIZE, "-1")); - return cacheSize == -1 ? Integer.MAX_VALUE : cacheSize; - } - - public static SharedPreferences getPreferences() { - return PreferenceManager.getDefaultSharedPreferences(appContext()); - } - - /** - * Get the contents of an InputStream as a byte[]. - *

- * This method buffers the input internally, so there is no need to use a - * BufferedInputStream. - * - * @param input the InputStream to read from - * @return the requested byte array - * @throws NullPointerException if the input is null - * @throws java.io.IOException if an I/O error occurs - */ - public static byte[] toByteArray(InputStream input) throws IOException - { - ByteArrayOutputStream output = new ByteArrayOutputStream(); - copy(input, output); - return output.toByteArray(); - } - - public static long copy(InputStream input, OutputStream output) throws IOException - { - byte[] buffer = new byte[1024 * 4]; - long count = 0; - int n; - - while (-1 != (n = input.read(buffer))) - { - output.write(buffer, 0, n); - count += n; - } - - return count; - } - - public static void atomicCopy(File from, File to) throws IOException - { - File tmp = new File(String.format("%s.tmp", to.getPath())); - FileInputStream in = new FileInputStream(from); - FileOutputStream out = new FileOutputStream(tmp); - - try - { - in.getChannel().transferTo(0, from.length(), out.getChannel()); - out.close(); - - if (!tmp.renameTo(to)) - { - throw new IOException(String.format("Failed to rename %s to %s", tmp, to)); - } - - Timber.i("Copied %s to %s", from, to); - } - catch (IOException x) - { - close(out); - delete(to); - throw x; - } - finally - { - close(in); - close(out); - delete(tmp); - } - } - - public static void renameFile(File from, File to) throws IOException - { - if (from.renameTo(to)) - { - Timber.i("Renamed %s to %s", from, to); - } - else - { - atomicCopy(from, to); - } - } - - public static void close(Closeable closeable) - { - try - { - if (closeable != null) - { - closeable.close(); - } - } - catch (Throwable x) - { - // Ignored - } - } - - public static boolean delete(File file) - { - if (file != null && file.exists()) - { - if (!file.delete()) - { - Timber.w("Failed to delete file %s", file); - return false; - } - - Timber.i("Deleted file %s", file); - } - return true; - } - - public static void toast(Context context, int messageId) - { - toast(context, messageId, true); - } - - public static void toast(Context context, int messageId, boolean shortDuration) - { - toast(context, context.getString(messageId), shortDuration); - } - - public static void toast(Context context, CharSequence message) - { - toast(context, message, true); - } - - @SuppressLint("ShowToast") // Invalid warning - public static void toast(Context context, CharSequence message, boolean shortDuration) - { - if (toast == null) - { - toast = Toast.makeText(context, message, shortDuration ? Toast.LENGTH_SHORT : Toast.LENGTH_LONG); - toast.setGravity(Gravity.CENTER, 0, 0); - } - else - { - toast.setText(message); - toast.setDuration(shortDuration ? Toast.LENGTH_SHORT : Toast.LENGTH_LONG); - } - toast.show(); - } - - - /** - * Formats an Int to a percentage string - * For instance: - *

    - *
  • format(99) returns "99 %".
  • - *
- * - * @param percent The percent as a range from 0 - 100 - * @return The formatted string. - */ - public static synchronized String formatPercentage(int percent) - { - return Math.min(Math.max(percent,0),100) + " %"; - } - - - /** - * Converts a byte-count to a formatted string suitable for display to the user. - * For instance: - *
    - *
  • format(918) returns "918 B".
  • - *
  • format(98765) returns "96 KB".
  • - *
  • format(1238476) returns "1.2 MB".
  • - *
- * This method assumes that 1 KB is 1024 bytes. - * To get a localized string, please use formatLocalizedBytes instead. - * - * @param byteCount The number of bytes. - * @return The formatted string. - */ - public static synchronized String formatBytes(long byteCount) - { - - // More than 1 GB? - if (byteCount >= 1024 * 1024 * 1024) - { - return GIGA_BYTE_FORMAT.format((double) byteCount / (1024 * 1024 * 1024)); - } - - // More than 1 MB? - if (byteCount >= 1024 * 1024) - { - return MEGA_BYTE_FORMAT.format((double) byteCount / (1024 * 1024)); - } - - // More than 1 KB? - if (byteCount >= 1024) - { - return KILO_BYTE_FORMAT.format((double) byteCount / 1024); - } - - return byteCount + " B"; - } - - /** - * Converts a byte-count to a formatted string suitable for display to the user. - * For instance: - *
    - *
  • format(918) returns "918 B".
  • - *
  • format(98765) returns "96 KB".
  • - *
  • format(1238476) returns "1.2 MB".
  • - *
- * This method assumes that 1 KB is 1024 bytes. - * This version of the method returns a localized string. - * - * @param byteCount The number of bytes. - * @return The formatted string. - */ - public static synchronized String formatLocalizedBytes(long byteCount, Context context) - { - - // More than 1 GB? - if (byteCount >= 1024 * 1024 * 1024) - { - if (GIGA_BYTE_LOCALIZED_FORMAT == null) - { - GIGA_BYTE_LOCALIZED_FORMAT = new DecimalFormat(context.getResources().getString(R.string.util_bytes_format_gigabyte)); - } - - return GIGA_BYTE_LOCALIZED_FORMAT.format((double) byteCount / (1024 * 1024 * 1024)); - } - - // More than 1 MB? - if (byteCount >= 1024 * 1024) - { - if (MEGA_BYTE_LOCALIZED_FORMAT == null) - { - MEGA_BYTE_LOCALIZED_FORMAT = new DecimalFormat(context.getResources().getString(R.string.util_bytes_format_megabyte)); - } - - return MEGA_BYTE_LOCALIZED_FORMAT.format((double) byteCount / (1024 * 1024)); - } - - // More than 1 KB? - if (byteCount >= 1024) - { - if (KILO_BYTE_LOCALIZED_FORMAT == null) - { - KILO_BYTE_LOCALIZED_FORMAT = new DecimalFormat(context.getResources().getString(R.string.util_bytes_format_kilobyte)); - } - - return KILO_BYTE_LOCALIZED_FORMAT.format((double) byteCount / 1024); - } - - if (BYTE_LOCALIZED_FORMAT == null) - { - BYTE_LOCALIZED_FORMAT = new DecimalFormat(context.getResources().getString(R.string.util_bytes_format_byte)); - } - - return BYTE_LOCALIZED_FORMAT.format((double) byteCount); - } - - public static boolean equals(Object object1, Object object2) - { - return object1 == object2 || !(object1 == null || object2 == null) && object1.equals(object2); - } - - /** - * Encodes the given string by using the hexadecimal representation of its UTF-8 bytes. - * - * @param s The string to encode. - * @return The encoded string. - */ - public static String utf8HexEncode(String s) - { - if (s == null) - { - return null; - } - - byte[] utf8; - - try - { - utf8 = s.getBytes(Constants.UTF_8); - } - catch (UnsupportedEncodingException x) - { - throw new RuntimeException(x); - } - - return hexEncode(utf8); - } - - /** - * Converts an array of bytes into an array of characters representing the hexadecimal values of each byte in order. - * The returned array will be double the length of the passed array, as it takes two characters to represent any - * given byte. - * - * @param data Bytes to convert to hexadecimal characters. - * @return A string containing hexadecimal characters. - */ - public static String hexEncode(byte[] data) - { - int length = data.length; - char[] out = new char[length << 1]; - int j = 0; - - // two characters form the hex value. - for (byte aData : data) - { - out[j++] = HEX_DIGITS[(0xF0 & aData) >>> 4]; - out[j++] = HEX_DIGITS[0x0F & aData]; - } - - return new String(out); - } - - /** - * Calculates the MD5 digest and returns the value as a 32 character hex string. - * - * @param s Data to digest. - * @return MD5 digest as a hex string. - */ - public static String md5Hex(String s) - { - if (s == null) - { - return null; - } - - try - { - MessageDigest md5 = MessageDigest.getInstance("MD5"); - return hexEncode(md5.digest(s.getBytes(Constants.UTF_8))); - } - catch (Exception x) - { - throw new RuntimeException(x.getMessage(), x); - } - } - - public static String getGrandparent(final String path) - { - // Find the top level folder, assume it is the album artist - if (path != null) - { - int slashIndex = path.indexOf('/'); - - if (slashIndex > 0) - { - return path.substring(0, slashIndex); - } - } - - return null; - } - - public static boolean isNetworkConnected() - { - ConnectivityManager manager = getConnectivityManager(); - NetworkInfo networkInfo = manager.getActiveNetworkInfo(); - boolean connected = networkInfo != null && networkInfo.isConnected(); - - boolean wifiConnected = connected && networkInfo.getType() == ConnectivityManager.TYPE_WIFI; - boolean wifiRequired = isWifiRequiredForDownload(); - - return connected && (!wifiRequired || wifiConnected); - } - - public static boolean isExternalStoragePresent() - { - return Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState()); - } - - private static boolean isWifiRequiredForDownload() - { - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_WIFI_REQUIRED_FOR_DOWNLOAD, false); - } - - public static boolean shouldDisplayBitrateWithArtist() - { - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_DISPLAY_BITRATE_WITH_ARTIST, true); - } - - public static boolean shouldUseFolderForArtistName() - { - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_USE_FOLDER_FOR_ALBUM_ARTIST, false); - } - - public static boolean shouldShowTrackNumber() - { - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_SHOW_TRACK_NUMBER, false); - } - - - // The AlertDialog requires an Activity context, app context is not enough - // See https://stackoverflow.com/questions/5436822/ - public static void showDialog(Context context, int icon, int titleId, String message) - { - new AlertDialog.Builder(context) - .setIcon(icon) - .setTitle(titleId) - .setMessage(message) - .setPositiveButton(R.string.common_ok, (dialog, i) -> dialog.dismiss()) - .show(); - } - - - public static void sleepQuietly(long millis) - { - try - { - Thread.sleep(millis); - } - catch (InterruptedException x) - { - Timber.w(x, "Interrupted from sleep."); - } - } - - public static Drawable getDrawableFromAttribute(Context context, int attr) - { - int[] attrs = new int[]{attr}; - TypedArray ta = context.obtainStyledAttributes(attrs); - Drawable drawableFromTheme = null; - - if (ta != null) - { - drawableFromTheme = ta.getDrawable(0); - ta.recycle(); - } - - return drawableFromTheme; - } - - public static Drawable createDrawableFromBitmap(Context context, Bitmap bitmap) - { - return new BitmapDrawable(context.getResources(), bitmap); - } - - public static Bitmap createBitmapFromDrawable(Drawable drawable) { - if (drawable instanceof BitmapDrawable) { - return ((BitmapDrawable)drawable).getBitmap(); - } - - Bitmap bitmap = Bitmap.createBitmap(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888); - Canvas canvas = new Canvas(bitmap); - drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); - drawable.draw(canvas); - - return bitmap; - } - - public static WifiManager.WifiLock createWifiLock(String tag) - { - WifiManager wm = (WifiManager) appContext().getApplicationContext().getSystemService(Context.WIFI_SERVICE); - return wm.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, tag); - } - - public static int getScaledHeight(double height, double width, int newWidth) - { - // Try to keep correct aspect ratio of the original image, do not force a square - double aspectRatio = height / width; - - // Assume the size given refers to the width of the image, so calculate the new height using - // the previously determined aspect ratio - return (int) Math.round(newWidth * aspectRatio); - } - - public static int getScaledHeight(Bitmap bitmap, int width) - { - return getScaledHeight(bitmap.getHeight(), bitmap.getWidth(), width); - } - - public static Bitmap scaleBitmap(Bitmap bitmap, int size) - { - if (bitmap == null) return null; - return Bitmap.createScaledBitmap(bitmap, size, getScaledHeight(bitmap, size), true); - } - - // Trigger an update on the MediaSession. Depending on the preference it will register - // or deregister the MediaButtonReceiver. - public static void updateMediaButtonEventReceiver() - { - MediaPlayerService mediaPlayerService = MediaPlayerService.getRunningInstance(); - if (mediaPlayerService != null) { - mediaPlayerService.updateMediaButtonReceiver(); - } - } - - public static MusicDirectory getSongsFromSearchResult(SearchResult searchResult) - { - MusicDirectory musicDirectory = new MusicDirectory(); - - for (Entry entry : searchResult.getSongs()) - { - musicDirectory.addChild(entry); - } - - return musicDirectory; - } - - public static MusicDirectory getSongsFromBookmarks(Iterable bookmarks) { - MusicDirectory musicDirectory = new MusicDirectory(); - - MusicDirectory.Entry song; - for (Bookmark bookmark : bookmarks) { - song = bookmark.getEntry(); - song.setBookmarkPosition(bookmark.getPosition()); - musicDirectory.addChild(song); - } - - return musicDirectory; - } - - /** - *

Broadcasts the given song info as the new song being played.

- */ - public static void broadcastNewTrackInfo(Context context, Entry song) - { - Intent intent = new Intent(EVENT_META_CHANGED); - - if (song != null) - { - intent.putExtra("title", song.getTitle()); - intent.putExtra("artist", song.getArtist()); - intent.putExtra("album", song.getAlbum()); - - File albumArtFile = FileUtil.getAlbumArtFile(song); - intent.putExtra("coverart", albumArtFile.getAbsolutePath()); - } - else - { - intent.putExtra("title", ""); - intent.putExtra("artist", ""); - intent.putExtra("album", ""); - intent.putExtra("coverart", ""); - } - - context.sendBroadcast(intent); - } - - public static void broadcastA2dpMetaDataChange(Context context, int playerPosition, DownloadFile currentPlaying, int listSize, int id) - { - if (!Util.getShouldSendBluetoothNotifications()) - { - return; - } - - Entry song = null; - Intent avrcpIntent = new Intent(CM_AVRCP_METADATA_CHANGED); - - if (currentPlaying != null) song = currentPlaying.getSong(); - - if (song == null) - { - avrcpIntent.putExtra("track", ""); - avrcpIntent.putExtra("track_name", ""); - avrcpIntent.putExtra("artist", ""); - avrcpIntent.putExtra("artist_name", ""); - avrcpIntent.putExtra("album", ""); - avrcpIntent.putExtra("album_name", ""); - avrcpIntent.putExtra("album_artist", ""); - avrcpIntent.putExtra("album_artist_name", ""); - - if (Util.getShouldSendBluetoothAlbumArt()) - { - avrcpIntent.putExtra("coverart", (Parcelable) null); - avrcpIntent.putExtra("cover", (Parcelable) null); - } - - avrcpIntent.putExtra("ListSize", (long) 0); - avrcpIntent.putExtra("id", (long) 0); - avrcpIntent.putExtra("duration", (long) 0); - avrcpIntent.putExtra("position", (long) 0); - } - else - { - if (song != currentSong) - { - currentSong = song; - } - - String title = song.getTitle(); - String artist = song.getArtist(); - String album = song.getAlbum(); - Integer duration = song.getDuration(); - - avrcpIntent.putExtra("track", title); - avrcpIntent.putExtra("track_name", title); - avrcpIntent.putExtra("artist", artist); - avrcpIntent.putExtra("artist_name", artist); - avrcpIntent.putExtra("album", album); - avrcpIntent.putExtra("album_name", album); - avrcpIntent.putExtra("album_artist", artist); - avrcpIntent.putExtra("album_artist_name", artist); - - - if (Util.getShouldSendBluetoothAlbumArt()) - { - File albumArtFile = FileUtil.getAlbumArtFile(song); - avrcpIntent.putExtra("coverart", albumArtFile.getAbsolutePath()); - avrcpIntent.putExtra("cover", albumArtFile.getAbsolutePath()); - } - - avrcpIntent.putExtra("position", (long) playerPosition); - avrcpIntent.putExtra("id", (long) id); - avrcpIntent.putExtra("ListSize", (long) listSize); - - if (duration != null) - { - avrcpIntent.putExtra("duration", (long) duration); - } - } - - context.sendBroadcast(avrcpIntent); - } - - public static void broadcastA2dpPlayStatusChange(Context context, PlayerState state, Entry currentSong, Integer listSize, Integer id, Integer playerPosition) - { - if (!Util.getShouldSendBluetoothNotifications()) - { - return; - } - - if (currentSong != null) - { - Intent avrcpIntent = new Intent(CM_AVRCP_PLAYSTATE_CHANGED); - - if (currentSong == null) - { - return; - } - - // FIXME: This is probably a bug. - if (currentSong != currentSong) - { - Util.currentSong = currentSong; - } - - String title = currentSong.getTitle(); - String artist = currentSong.getArtist(); - String album = currentSong.getAlbum(); - Integer duration = currentSong.getDuration(); - - avrcpIntent.putExtra("track", title); - avrcpIntent.putExtra("track_name", title); - avrcpIntent.putExtra("artist", artist); - avrcpIntent.putExtra("artist_name", artist); - avrcpIntent.putExtra("album", album); - avrcpIntent.putExtra("album_name", album); - avrcpIntent.putExtra("album_artist", artist); - avrcpIntent.putExtra("album_artist_name", artist); - - if (Util.getShouldSendBluetoothAlbumArt()) - { - File albumArtFile = FileUtil.getAlbumArtFile(currentSong); - avrcpIntent.putExtra("coverart", albumArtFile.getAbsolutePath()); - avrcpIntent.putExtra("cover", albumArtFile.getAbsolutePath()); - } - - avrcpIntent.putExtra("position", (long) playerPosition); - avrcpIntent.putExtra("id", (long) id); - avrcpIntent.putExtra("ListSize", (long) listSize); - - if (duration != null) - { - avrcpIntent.putExtra("duration", (long) duration); - } - - switch (state) - { - case STARTED: - avrcpIntent.putExtra("playing", true); - break; - case STOPPED: - case PAUSED: - case COMPLETED: - avrcpIntent.putExtra("playing", false); - break; - default: - return; // No need to broadcast. - } - - context.sendBroadcast(avrcpIntent); - } - } - - /** - *

Broadcasts the given player state as the one being set.

- */ - public static void broadcastPlaybackStatusChange(Context context, PlayerState state) - { - Intent intent = new Intent(EVENT_PLAYSTATE_CHANGED); - - switch (state) - { - case STARTED: - intent.putExtra("state", "play"); - break; - case STOPPED: - intent.putExtra("state", "stop"); - break; - case PAUSED: - intent.putExtra("state", "pause"); - break; - case COMPLETED: - intent.putExtra("state", "complete"); - break; - default: - return; // No need to broadcast. - } - - context.sendBroadcast(intent); - } - - public static int getNotificationImageSize(Context context) - { - DisplayMetrics metrics = context.getResources().getDisplayMetrics(); - int imageSizeLarge = Math.round(Math.min(metrics.widthPixels, metrics.heightPixels)); - - int size; - - if (imageSizeLarge <= 480) - { - size = 64; - } - - else size = imageSizeLarge <= 768 ? 128 : 256; - - return size; - } - - public static int getAlbumImageSize(Context context) - { - DisplayMetrics metrics = context.getResources().getDisplayMetrics(); - int imageSizeLarge = Math.round(Math.min(metrics.widthPixels, metrics.heightPixels)); - - int size; - - if (imageSizeLarge <= 480) - { - size = 128; - } - - else size = imageSizeLarge <= 768 ? 256 : 512; - - return size; - } - - public static int getMinDisplayMetric() - { - DisplayMetrics metrics = appContext().getResources().getDisplayMetrics(); - return Math.min(metrics.widthPixels, metrics.heightPixels); - } - - public static int getMaxDisplayMetric() - { - DisplayMetrics metrics = appContext().getResources().getDisplayMetrics(); - return Math.max(metrics.widthPixels, metrics.heightPixels); - } - - public static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) - { - // Raw height and width of image - final int height = options.outHeight; - final int width = options.outWidth; - int inSampleSize = 1; - - if (height > reqHeight || width > reqWidth) - { - - // Calculate ratios of height and width to requested height and - // width - final int heightRatio = Math.round((float) height / (float) reqHeight); - final int widthRatio = Math.round((float) width / (float) reqWidth); - - // Choose the smallest ratio as inSampleSize value, this will - // guarantee - // a final image with both dimensions larger than or equal to the - // requested height and width. - inSampleSize = Math.min(heightRatio, widthRatio); - } - - return inSampleSize; - } - - public static int getDefaultAlbums() - { - SharedPreferences preferences = getPreferences(); - return Integer.parseInt(preferences.getString(Constants.PREFERENCES_KEY_DEFAULT_ALBUMS, "5")); - } - - public static int getMaxAlbums() - { - SharedPreferences preferences = getPreferences(); - return Integer.parseInt(preferences.getString(Constants.PREFERENCES_KEY_MAX_ALBUMS, "20")); - } - - public static int getDefaultSongs() - { - SharedPreferences preferences = getPreferences(); - return Integer.parseInt(preferences.getString(Constants.PREFERENCES_KEY_DEFAULT_SONGS, "10")); - } - - public static int getMaxSongs() - { - SharedPreferences preferences = getPreferences(); - return Integer.parseInt(preferences.getString(Constants.PREFERENCES_KEY_MAX_SONGS, "25")); - } - - public static int getMaxArtists() - { - SharedPreferences preferences = getPreferences(); - return Integer.parseInt(preferences.getString(Constants.PREFERENCES_KEY_MAX_ARTISTS, "10")); - } - - public static int getDefaultArtists() - { - SharedPreferences preferences = getPreferences(); - return Integer.parseInt(preferences.getString(Constants.PREFERENCES_KEY_DEFAULT_ARTISTS, "3")); - } - - public static int getBufferLength() - { - SharedPreferences preferences = getPreferences(); - return Integer.parseInt(preferences.getString(Constants.PREFERENCES_KEY_BUFFER_LENGTH, "5")); - } - - public static int getIncrementTime() - { - SharedPreferences preferences = getPreferences(); - return Integer.parseInt(preferences.getString(Constants.PREFERENCES_KEY_INCREMENT_TIME, "5")); - } - - public static boolean getMediaButtonsEnabled() - { - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_MEDIA_BUTTONS, true); - } - - public static boolean getShowNowPlayingPreference() - { - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_SHOW_NOW_PLAYING, true); - } - - public static boolean getGaplessPlaybackPreference() - { - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_GAPLESS_PLAYBACK, false); - } - - public static boolean getShouldTransitionOnPlaybackPreference() - { - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_DOWNLOAD_TRANSITION, true); - } - - public static boolean getShouldUseId3Tags() - { - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_ID3_TAGS, false); - } - - public static boolean getShouldShowArtistPicture() - { - SharedPreferences preferences = getPreferences(); - boolean isOffline = ActiveServerProvider.Companion.isOffline(); - boolean isId3Enabled = preferences.getBoolean(Constants.PREFERENCES_KEY_ID3_TAGS, false); - boolean shouldShowArtistPicture = preferences.getBoolean(Constants.PREFERENCES_KEY_SHOW_ARTIST_PICTURE, false); - return (!isOffline) && isId3Enabled && shouldShowArtistPicture; - } - - public static int getChatRefreshInterval() - { - SharedPreferences preferences = getPreferences(); - return Integer.parseInt(preferences.getString(Constants.PREFERENCES_KEY_CHAT_REFRESH_INTERVAL, "5000")); - } - - public static int getDirectoryCacheTime() - { - SharedPreferences preferences = getPreferences(); - return Integer.parseInt(preferences.getString(Constants.PREFERENCES_KEY_DIRECTORY_CACHE_TIME, "300")); - } - - public static boolean isNullOrWhiteSpace(String string) - { - return string == null || string.isEmpty() || string.trim().isEmpty(); - } - - public static String formatTotalDuration(long totalDuration) - { - return formatTotalDuration(totalDuration, false); - } - - public static boolean getShouldClearPlaylist() - { - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_CLEAR_PLAYLIST, false); - } - - public static boolean getShouldSortByDisc() - { - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_DISC_SORT, false); - } - - public static boolean getShouldClearBookmark() - { - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_CLEAR_BOOKMARK, false); - } - - public static boolean getSingleButtonPlayPause() - { - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_SINGLE_BUTTON_PLAY_PAUSE, false); - } - - public static String formatTotalDuration(long totalDuration, boolean inMilliseconds) - { - long millis = totalDuration; - - if (!inMilliseconds) - { - millis = totalDuration * 1000; - } - - long hours = TimeUnit.MILLISECONDS.toHours(millis); - long minutes = TimeUnit.MILLISECONDS.toMinutes(millis) - TimeUnit.HOURS.toMinutes(hours); - long seconds = TimeUnit.MILLISECONDS.toSeconds(millis) - TimeUnit.MINUTES.toSeconds(hours * 60 + minutes); - - if (hours >= 10) - { - return String.format(Locale.getDefault(), "%02d:%02d:%02d", hours, minutes, seconds); - } - else if (hours > 0) - { - return String.format(Locale.getDefault(), "%d:%02d:%02d", hours, minutes, seconds); - } - else if (minutes >= 10) - { - return String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds); - } - - else return minutes > 0 ? String.format(Locale.getDefault(), "%d:%02d", minutes, seconds) : String.format(Locale.getDefault(), "0:%02d", seconds); - } - - public static String getVersionName(Context context) - { - String versionName = null; - - PackageManager pm = context.getPackageManager(); - - if (pm != null) - { - String packageName = context.getPackageName(); - - try - { - versionName = pm.getPackageInfo(packageName, 0).versionName; - } - catch (PackageManager.NameNotFoundException ignored) - { - - } - } - - return versionName; - } - - public static int getVersionCode(Context context) - { - int versionCode = 0; - - PackageManager pm = context.getPackageManager(); - - if (pm != null) - { - String packageName = context.getPackageName(); - - try - { - versionCode = pm.getPackageInfo(packageName, 0).versionCode; - } - catch (PackageManager.NameNotFoundException ignored) - { - - } - } - - return versionCode; - } - - @SuppressWarnings("BooleanMethodIsAlwaysInverted") // Inverted for readability - public static boolean getShouldSendBluetoothNotifications() - { - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_SEND_BLUETOOTH_NOTIFICATIONS, true); - } - - public static boolean getShouldSendBluetoothAlbumArt() - { - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_SEND_BLUETOOTH_ALBUM_ART, true); - } - - public static int getViewRefreshInterval() - { - SharedPreferences preferences = getPreferences(); - return Integer.parseInt(preferences.getString(Constants.PREFERENCES_KEY_VIEW_REFRESH, "1000")); - } - - public static boolean getShouldAskForShareDetails() - { - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_ASK_FOR_SHARE_DETAILS, true); - } - - public static String getDefaultShareDescription() - { - SharedPreferences preferences = getPreferences(); - return preferences.getString(Constants.PREFERENCES_KEY_DEFAULT_SHARE_DESCRIPTION, ""); - } - - public static String getShareGreeting() - { - SharedPreferences preferences = getPreferences(); - Context context = appContext(); - String defaultVal = String.format(context.getResources().getString(R.string.share_default_greeting), context.getResources().getString(R.string.common_appname)); - return preferences.getString(Constants.PREFERENCES_KEY_DEFAULT_SHARE_GREETING, defaultVal); - } - - public static String getDefaultShareExpiration() - { - SharedPreferences preferences = getPreferences(); - return preferences.getString(Constants.PREFERENCES_KEY_DEFAULT_SHARE_EXPIRATION, "0"); - } - - public static long getDefaultShareExpirationInMillis(Context context) - { - SharedPreferences preferences = getPreferences(); - String preference = preferences.getString(Constants.PREFERENCES_KEY_DEFAULT_SHARE_EXPIRATION, "0"); - - String[] split = PATTERN.split(preference); - - if (split.length == 2) - { - int timeSpanAmount = Integer.parseInt(split[0]); - String timeSpanType = split[1]; - - TimeSpan timeSpan = TimeSpanPicker.calculateTimeSpan(context, timeSpanType, timeSpanAmount); - - return timeSpan.getTotalMilliseconds(); - } - - return 0; - } - - public static void setShouldAskForShareDetails(boolean shouldAskForShareDetails) - { - SharedPreferences preferences = getPreferences(); - SharedPreferences.Editor editor = preferences.edit(); - editor.putBoolean(Constants.PREFERENCES_KEY_ASK_FOR_SHARE_DETAILS, shouldAskForShareDetails); - editor.apply(); - } - - public static void setDefaultShareExpiration(String defaultShareExpiration) - { - SharedPreferences preferences = getPreferences(); - SharedPreferences.Editor editor = preferences.edit(); - editor.putString(Constants.PREFERENCES_KEY_DEFAULT_SHARE_EXPIRATION, defaultShareExpiration); - editor.apply(); - } - - public static void setDefaultShareDescription(String defaultShareDescription) - { - SharedPreferences preferences = getPreferences(); - SharedPreferences.Editor editor = preferences.edit(); - editor.putString(Constants.PREFERENCES_KEY_DEFAULT_SHARE_DESCRIPTION, defaultShareDescription); - editor.apply(); - } - - public static boolean getShouldShowAllSongsByArtist() - { - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_SHOW_ALL_SONGS_BY_ARTIST, false); - } - - public static void scanMedia(File file) - { - Uri uri = Uri.fromFile(file); - Intent scanFileIntent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, uri); - appContext().sendBroadcast(scanFileIntent); - } - - public static int getImageLoaderConcurrency() - { - SharedPreferences preferences = getPreferences(); - return Integer.parseInt(preferences.getString(Constants.PREFERENCES_KEY_IMAGE_LOADER_CONCURRENCY, "5")); - } - - public static int getResourceFromAttribute(Context context, int resId) - { - TypedValue typedValue = new TypedValue(); - Resources.Theme theme = context.getTheme(); - theme.resolveAttribute(resId, typedValue, true); - return typedValue.resourceId; - } - - public static boolean isFirstRun() - { - SharedPreferences preferences = getPreferences(); - boolean firstExecuted = preferences.getBoolean(Constants.PREFERENCES_KEY_FIRST_RUN_EXECUTED, false); - if (firstExecuted) return false; - SharedPreferences.Editor editor = preferences.edit(); - editor.putBoolean(Constants.PREFERENCES_KEY_FIRST_RUN_EXECUTED, true); - editor.apply(); - return true; - } - - public static int getResumeOnBluetoothDevice() - { - SharedPreferences preferences = getPreferences(); - return preferences.getInt(Constants.PREFERENCES_KEY_RESUME_ON_BLUETOOTH_DEVICE, Constants.PREFERENCE_VALUE_DISABLED); - } - - public static int getPauseOnBluetoothDevice() - { - SharedPreferences preferences = getPreferences(); - return preferences.getInt(Constants.PREFERENCES_KEY_PAUSE_ON_BLUETOOTH_DEVICE, Constants.PREFERENCE_VALUE_A2DP); - } - - public static boolean getDebugLogToFile() - { - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_DEBUG_LOG_TO_FILE, false); - } - - public static void hideKeyboard(Activity activity) { - InputMethodManager inputManager = (InputMethodManager) activity.getSystemService(Context.INPUT_METHOD_SERVICE); - - View currentFocusedView = activity.getCurrentFocus(); - if (currentFocusedView != null) { - inputManager.hideSoftInputFromWindow(currentFocusedView.getWindowToken(), InputMethodManager.HIDE_NOT_ALWAYS); - } - } -} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt index e78404bf..e44e0774 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt @@ -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() } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt index 6f7a751d..f50883d6 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt @@ -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. diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt index af4f7be9..718f34a1 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt @@ -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) { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt new file mode 100644 index 00000000..5bdfe45b --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt @@ -0,0 +1,1101 @@ +/* + * AutoMediaBrowserService.kt + * Copyright (C) 2009-2021 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.service + +import android.os.Bundle +import android.os.Handler +import android.support.v4.media.MediaBrowserCompat +import android.support.v4.media.MediaDescriptionCompat +import android.support.v4.media.session.MediaSessionCompat +import androidx.media.MediaBrowserServiceCompat +import androidx.media.utils.MediaConstants +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject +import org.moire.ultrasonic.R +import org.moire.ultrasonic.api.subsonic.models.AlbumListType +import org.moire.ultrasonic.data.ActiveServerProvider +import org.moire.ultrasonic.domain.MusicDirectory +import org.moire.ultrasonic.domain.SearchCriteria +import org.moire.ultrasonic.domain.SearchResult +import org.moire.ultrasonic.util.MediaSessionEventDistributor +import org.moire.ultrasonic.util.MediaSessionEventListener +import org.moire.ultrasonic.util.MediaSessionHandler +import org.moire.ultrasonic.util.Util +import timber.log.Timber + +private const val MEDIA_ROOT_ID = "MEDIA_ROOT_ID" +private const val MEDIA_ALBUM_ID = "MEDIA_ALBUM_ID" +private const val MEDIA_ALBUM_PAGE_ID = "MEDIA_ALBUM_PAGE_ID" +private const val MEDIA_ALBUM_NEWEST_ID = "MEDIA_ALBUM_NEWEST_ID" +private const val MEDIA_ALBUM_RECENT_ID = "MEDIA_ALBUM_RECENT_ID" +private const val MEDIA_ALBUM_FREQUENT_ID = "MEDIA_ALBUM_FREQUENT_ID" +private const val MEDIA_ALBUM_RANDOM_ID = "MEDIA_ALBUM_RANDOM_ID" +private const val MEDIA_ALBUM_STARRED_ID = "MEDIA_ALBUM_STARRED_ID" +private const val MEDIA_SONG_RANDOM_ID = "MEDIA_SONG_RANDOM_ID" +private const val MEDIA_SONG_STARRED_ID = "MEDIA_SONG_STARRED_ID" +private const val MEDIA_ARTIST_ID = "MEDIA_ARTIST_ID" +private const val MEDIA_LIBRARY_ID = "MEDIA_LIBRARY_ID" +private const val MEDIA_PLAYLIST_ID = "MEDIA_PLAYLIST_ID" +private const val MEDIA_SHARE_ID = "MEDIA_SHARE_ID" +private const val MEDIA_BOOKMARK_ID = "MEDIA_BOOKMARK_ID" +private const val MEDIA_PODCAST_ID = "MEDIA_PODCAST_ID" +private const val MEDIA_ALBUM_ITEM = "MEDIA_ALBUM_ITEM" +private const val MEDIA_PLAYLIST_SONG_ITEM = "MEDIA_PLAYLIST_SONG_ITEM" +private const val MEDIA_PLAYLIST_ITEM = "MEDIA_PLAYLIST_ITEM" +private const val MEDIA_ARTIST_ITEM = "MEDIA_ARTIST_ITEM" +private const val MEDIA_ARTIST_SECTION = "MEDIA_ARTIST_SECTION" +private const val MEDIA_ALBUM_SONG_ITEM = "MEDIA_ALBUM_SONG_ITEM" +private const val MEDIA_SONG_STARRED_ITEM = "MEDIA_SONG_STARRED_ITEM" +private const val MEDIA_SONG_RANDOM_ITEM = "MEDIA_SONG_RANDOM_ITEM" +private const val MEDIA_SHARE_ITEM = "MEDIA_SHARE_ITEM" +private const val MEDIA_SHARE_SONG_ITEM = "MEDIA_SHARE_SONG_ITEM" +private const val MEDIA_BOOKMARK_ITEM = "MEDIA_BOOKMARK_ITEM" +private const val MEDIA_PODCAST_ITEM = "MEDIA_PODCAST_ITEM" +private const val MEDIA_PODCAST_EPISODE_ITEM = "MEDIA_PODCAST_EPISODE_ITEM" +private const val MEDIA_SEARCH_SONG_ITEM = "MEDIA_SEARCH_SONG_ITEM" + +// Currently the display limit for long lists is 100 items +private const val DISPLAY_LIMIT = 100 +private const val SEARCH_LIMIT = 10 + +/** + * MediaBrowserService implementation for e.g. Android Auto + */ +@Suppress("TooManyFunctions", "LargeClass") +class AutoMediaBrowserService : MediaBrowserServiceCompat() { + + private lateinit var mediaSessionEventListener: MediaSessionEventListener + private val mediaSessionEventDistributor by inject() + private val lifecycleSupport by inject() + private val mediaSessionHandler by inject() + private val mediaPlayerController by inject() + private val activeServerProvider: ActiveServerProvider by inject() + private val musicService = MusicServiceFactory.getMusicService() + + private val serviceJob = Job() + private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob) + + private var playlistCache: List? = null + private var starredSongsCache: List? = null + private var randomSongsCache: List? = null + private var searchSongsCache: List? = null + + private val isOffline get() = ActiveServerProvider.isOffline() + private val useId3Tags get() = Util.getShouldUseId3Tags() + private val musicFolderId get() = activeServerProvider.getActiveServer().musicFolderId + + @Suppress("MagicNumber") + override fun onCreate() { + super.onCreate() + + mediaSessionEventListener = object : MediaSessionEventListener { + override fun onMediaSessionTokenCreated(token: MediaSessionCompat.Token) { + if (sessionToken == null) { + sessionToken = token + } + } + + override fun onPlayFromMediaIdRequested(mediaId: String?, extras: Bundle?) { + Timber.d( + "AutoMediaBrowserService onPlayFromMediaIdRequested called. mediaId: %s", + mediaId + ) + + if (mediaId == null) return + val mediaIdParts = mediaId.split('|') + + when (mediaIdParts.first()) { + MEDIA_PLAYLIST_ITEM -> playPlaylist(mediaIdParts[1], mediaIdParts[2]) + MEDIA_PLAYLIST_SONG_ITEM -> playPlaylistSong( + mediaIdParts[1], mediaIdParts[2], mediaIdParts[3] + ) + MEDIA_ALBUM_ITEM -> playAlbum(mediaIdParts[1], mediaIdParts[2]) + MEDIA_ALBUM_SONG_ITEM -> playAlbumSong( + mediaIdParts[1], mediaIdParts[2], mediaIdParts[3] + ) + MEDIA_SONG_STARRED_ID -> playStarredSongs() + MEDIA_SONG_STARRED_ITEM -> playStarredSong(mediaIdParts[1]) + MEDIA_SONG_RANDOM_ID -> playRandomSongs() + MEDIA_SONG_RANDOM_ITEM -> playRandomSong(mediaIdParts[1]) + MEDIA_SHARE_ITEM -> playShare(mediaIdParts[1]) + MEDIA_SHARE_SONG_ITEM -> playShareSong(mediaIdParts[1], mediaIdParts[2]) + MEDIA_BOOKMARK_ITEM -> playBookmark(mediaIdParts[1]) + MEDIA_PODCAST_ITEM -> playPodcast(mediaIdParts[1]) + MEDIA_PODCAST_EPISODE_ITEM -> playPodcastEpisode( + mediaIdParts[1], mediaIdParts[2] + ) + MEDIA_SEARCH_SONG_ITEM -> playSearch(mediaIdParts[1]) + } + } + + override fun onPlayFromSearchRequested(query: String?, extras: Bundle?) { + Timber.d("AutoMediaBrowserService onPlayFromSearchRequested query: %s", query) + if (query.isNullOrBlank()) playRandomSongs() + + serviceScope.launch { + val criteria = SearchCriteria(query!!, 0, 0, DISPLAY_LIMIT) + val searchResult = callWithErrorHandling { musicService.search(criteria) } + + // Try to find the best match + if (searchResult != null) { + val song = searchResult.songs + .asSequence() + .sortedByDescending { song -> song.starred } + .sortedByDescending { song -> song.averageRating } + .sortedByDescending { song -> song.userRating } + .sortedByDescending { song -> song.closeness } + .firstOrNull() + + if (song != null) playSong(song) + } + } + } + } + + mediaSessionEventDistributor.subscribe(mediaSessionEventListener) + mediaSessionHandler.initialize() + + val handler = Handler() + handler.postDelayed( + { + // Ultrasonic may be started from Android Auto. This boots up the necessary components. + Timber.d( + "AutoMediaBrowserService starting lifecycleSupport and MediaPlayerService..." + ) + lifecycleSupport.onCreate() + MediaPlayerService.getInstance() + }, + 100 + ) + + Timber.i("AutoMediaBrowserService onCreate finished") + } + + override fun onDestroy() { + super.onDestroy() + mediaSessionEventDistributor.unsubscribe(mediaSessionEventListener) + mediaSessionHandler.release() + serviceJob.cancel() + + Timber.i("AutoMediaBrowserService onDestroy finished") + } + + override fun onGetRoot( + clientPackageName: String, + clientUid: Int, + rootHints: Bundle? + ): BrowserRoot { + Timber.d( + "AutoMediaBrowserService onGetRoot called. clientPackageName: %s; clientUid: %d", + clientPackageName, clientUid + ) + + val extras = Bundle() + extras.putInt( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE, + MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM + ) + extras.putInt( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE, + MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM + ) + extras.putBoolean( + MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true + ) + + return BrowserRoot(MEDIA_ROOT_ID, extras) + } + + @Suppress("ReturnCount", "ComplexMethod") + override fun onLoadChildren( + parentId: String, + result: Result> + ) { + Timber.d("AutoMediaBrowserService onLoadChildren called. ParentId: %s", parentId) + + val parentIdParts = parentId.split('|') + + when (parentIdParts.first()) { + MEDIA_ROOT_ID -> return getRootItems(result) + MEDIA_LIBRARY_ID -> return getLibrary(result) + MEDIA_ARTIST_ID -> return getArtists(result) + MEDIA_ARTIST_SECTION -> return getArtists(result, parentIdParts[1]) + MEDIA_ALBUM_ID -> return getAlbums(result, AlbumListType.SORTED_BY_NAME) + MEDIA_ALBUM_PAGE_ID -> return getAlbums( + result, AlbumListType.fromName(parentIdParts[1]), parentIdParts[2].toInt() + ) + MEDIA_PLAYLIST_ID -> return getPlaylists(result) + MEDIA_ALBUM_FREQUENT_ID -> return getAlbums(result, AlbumListType.FREQUENT) + MEDIA_ALBUM_NEWEST_ID -> return getAlbums(result, AlbumListType.NEWEST) + MEDIA_ALBUM_RECENT_ID -> return getAlbums(result, AlbumListType.RECENT) + MEDIA_ALBUM_RANDOM_ID -> return getAlbums(result, AlbumListType.RANDOM) + MEDIA_ALBUM_STARRED_ID -> return getAlbums(result, AlbumListType.STARRED) + MEDIA_SONG_RANDOM_ID -> return getRandomSongs(result) + MEDIA_SONG_STARRED_ID -> return getStarredSongs(result) + MEDIA_SHARE_ID -> return getShares(result) + MEDIA_BOOKMARK_ID -> return getBookmarks(result) + MEDIA_PODCAST_ID -> return getPodcasts(result) + MEDIA_PLAYLIST_ITEM -> return getPlaylist(parentIdParts[1], parentIdParts[2], result) + MEDIA_ARTIST_ITEM -> return getAlbumsForArtist( + result, parentIdParts[1], parentIdParts[2] + ) + MEDIA_ALBUM_ITEM -> return getSongsForAlbum(result, parentIdParts[1], parentIdParts[2]) + MEDIA_SHARE_ITEM -> return getSongsForShare(result, parentIdParts[1]) + MEDIA_PODCAST_ITEM -> return getPodcastEpisodes(result, parentIdParts[1]) + else -> result.sendResult(mutableListOf()) + } + } + + override fun onSearch( + query: String, + extras: Bundle?, + result: Result> + ) { + Timber.d("AutoMediaBrowserService onSearch query: %s", query) + val mediaItems: MutableList = ArrayList() + result.detach() + + serviceScope.launch { + val criteria = SearchCriteria(query, SEARCH_LIMIT, SEARCH_LIMIT, SEARCH_LIMIT) + val searchResult = callWithErrorHandling { musicService.search(criteria) } + + // TODO Add More... button to categories + if (searchResult != null) { + searchResult.artists.map { artist -> + mediaItems.add( + artist.name ?: "", + listOf(MEDIA_ARTIST_ITEM, artist.id, artist.name).joinToString("|"), + null, + R.string.search_artists + ) + } + + searchResult.albums.map { album -> + mediaItems.add( + album.title ?: "", + listOf(MEDIA_ALBUM_ITEM, album.id, album.name) + .joinToString("|"), + null, + R.string.search_albums + ) + } + + searchSongsCache = searchResult.songs + searchResult.songs.map { song -> + mediaItems.add( + MediaBrowserCompat.MediaItem( + Util.getMediaDescriptionForEntry( + song, + listOf(MEDIA_SEARCH_SONG_ITEM, song.id).joinToString("|"), + R.string.search_songs + ), + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + ) + } + } + result.sendResult(mediaItems) + } + } + + private fun playSearch(id: String) { + serviceScope.launch { + // If there is no cache, we can't play the selected song. + if (searchSongsCache != null) { + val song = searchSongsCache!!.firstOrNull { x -> x.id == id } + if (song != null) playSong(song) + } + } + } + + private fun getRootItems(result: Result>) { + val mediaItems: MutableList = ArrayList() + + if (!isOffline) + mediaItems.add( + R.string.music_library_label, + MEDIA_LIBRARY_ID, + R.drawable.ic_library, + null + ) + + mediaItems.add( + R.string.main_artists_title, + MEDIA_ARTIST_ID, + R.drawable.ic_artist, + null + ) + + if (!isOffline) + mediaItems.add( + R.string.main_albums_title, + MEDIA_ALBUM_ID, + R.drawable.ic_menu_browse_dark, + null + ) + + mediaItems.add( + R.string.playlist_label, + MEDIA_PLAYLIST_ID, + R.drawable.ic_menu_playlists_dark, + null + ) + + result.sendResult(mediaItems) + } + + private fun getLibrary(result: Result>) { + val mediaItems: MutableList = ArrayList() + + // Songs + mediaItems.add( + R.string.main_songs_random, + MEDIA_SONG_RANDOM_ID, + null, + R.string.main_songs_title + ) + + mediaItems.add( + R.string.main_songs_starred, + MEDIA_SONG_STARRED_ID, + null, + R.string.main_songs_title + ) + + // Albums + mediaItems.add( + R.string.main_albums_newest, + MEDIA_ALBUM_NEWEST_ID, + null, + R.string.main_albums_title + ) + + mediaItems.add( + R.string.main_albums_recent, + MEDIA_ALBUM_RECENT_ID, + null, + R.string.main_albums_title + ) + + mediaItems.add( + R.string.main_albums_frequent, + MEDIA_ALBUM_FREQUENT_ID, + null, + R.string.main_albums_title + ) + + mediaItems.add( + R.string.main_albums_random, + MEDIA_ALBUM_RANDOM_ID, + null, + R.string.main_albums_title + ) + + mediaItems.add( + R.string.main_albums_starred, + MEDIA_ALBUM_STARRED_ID, + null, + R.string.main_albums_title + ) + + // Other + mediaItems.add(R.string.button_bar_shares, MEDIA_SHARE_ID, null, null) + mediaItems.add(R.string.button_bar_bookmarks, MEDIA_BOOKMARK_ID, null, null) + mediaItems.add(R.string.button_bar_podcasts, MEDIA_PODCAST_ID, null, null) + + result.sendResult(mediaItems) + } + + private fun getArtists( + result: Result>, + section: String? = null + ) { + val mediaItems: MutableList = ArrayList() + result.detach() + + serviceScope.launch { + val childMediaId: String + var artists = if (!isOffline && useId3Tags) { + childMediaId = MEDIA_ARTIST_ITEM + // TODO this list can be big so we're not refreshing. + // Maybe a refresh menu item can be added + callWithErrorHandling { musicService.getArtists(false) } + } else { + // This will be handled at getSongsForAlbum, which supports navigation + childMediaId = MEDIA_ALBUM_ITEM + callWithErrorHandling { musicService.getIndexes(musicFolderId, false) } + } + + if (artists != null) { + if (section != null) + artists = artists.filter { artist -> + getSectionFromName(artist.name ?: "") == section + } + + // If there are too many artists, create alphabetic index of them + if (section == null && artists.count() > DISPLAY_LIMIT) { + val index = mutableListOf() + // TODO This sort should use ignoredArticles somehow... + artists = artists.sortedBy { artist -> artist.name } + artists.map { artist -> + val currentSection = getSectionFromName(artist.name ?: "") + if (!index.contains(currentSection)) { + index.add(currentSection) + mediaItems.add( + currentSection, + listOf(MEDIA_ARTIST_SECTION, currentSection).joinToString("|"), + null + ) + } + } + } else { + artists.map { artist -> + mediaItems.add( + artist.name ?: "", + listOf(childMediaId, artist.id, artist.name).joinToString("|"), + null + ) + } + } + result.sendResult(mediaItems) + } + } + } + + private fun getAlbumsForArtist( + result: Result>, + id: String, + name: String + ) { + val mediaItems: MutableList = ArrayList() + result.detach() + serviceScope.launch { + val albums = if (!isOffline && useId3Tags) { + callWithErrorHandling { musicService.getArtist(id, name, false) } + } else { + callWithErrorHandling { musicService.getMusicDirectory(id, name, false) } + } + + albums?.getAllChild()?.map { album -> + mediaItems.add( + album.title ?: "", + listOf(MEDIA_ALBUM_ITEM, album.id, album.name) + .joinToString("|"), + null + ) + } + result.sendResult(mediaItems) + } + } + + private fun getSongsForAlbum( + result: Result>, + id: String, + name: String + ) { + val mediaItems: MutableList = ArrayList() + result.detach() + + serviceScope.launch { + val songs = listSongsInMusicService(id, name) + + if (songs != null) { + if (songs.getChildren(includeDirs = true, includeFiles = false).count() == 0 && + songs.getChildren(includeDirs = false, includeFiles = true).count() > 0 + ) + mediaItems.addPlayAllItem(listOf(MEDIA_ALBUM_ITEM, id, name).joinToString("|")) + + // TODO: Paging is not implemented for songs, is it necessary at all? + val items = songs.getChildren().take(DISPLAY_LIMIT) + items.map { item -> + if (item.isDirectory) + mediaItems.add( + MediaBrowserCompat.MediaItem( + Util.getMediaDescriptionForEntry( + item, + listOf(MEDIA_ALBUM_ITEM, item.id, item.name).joinToString("|") + ), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + else + mediaItems.add( + MediaBrowserCompat.MediaItem( + Util.getMediaDescriptionForEntry( + item, + listOf( + MEDIA_ALBUM_SONG_ITEM, + id, + name, + item.id + ).joinToString("|") + ), + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + ) + } + } + result.sendResult(mediaItems) + } + } + + private fun getAlbums( + result: Result>, + type: AlbumListType, + page: Int? = null + ) { + val mediaItems: MutableList = ArrayList() + result.detach() + serviceScope.launch { + val offset = (page ?: 0) * DISPLAY_LIMIT + val albums = if (useId3Tags) { + callWithErrorHandling { + musicService.getAlbumList2( + type.typeName, DISPLAY_LIMIT, offset, null + ) + } + } else { + callWithErrorHandling { + musicService.getAlbumList( + type.typeName, DISPLAY_LIMIT, offset, null + ) + } + } + + albums?.getAllChild()?.map { album -> + mediaItems.add( + album.title ?: "", + listOf(MEDIA_ALBUM_ITEM, album.id, album.name) + .joinToString("|"), + null + ) + } + + if (albums?.getAllChild()?.count() ?: 0 >= DISPLAY_LIMIT) + mediaItems.add( + R.string.search_more, + listOf(MEDIA_ALBUM_PAGE_ID, type.typeName, (page ?: 0) + 1).joinToString("|"), + R.drawable.ic_menu_forward_dark, + null + ) + + result.sendResult(mediaItems) + } + } + + private fun getPlaylists(result: Result>) { + val mediaItems: MutableList = ArrayList() + result.detach() + + serviceScope.launch { + val playlists = callWithErrorHandling { musicService.getPlaylists(true) } + playlists?.map { playlist -> + mediaItems.add( + playlist.name, + listOf(MEDIA_PLAYLIST_ITEM, playlist.id, playlist.name) + .joinToString("|"), + null + ) + } + result.sendResult(mediaItems) + } + } + + private fun getPlaylist( + id: String, + name: String, + result: Result> + ) { + val mediaItems: MutableList = ArrayList() + result.detach() + + serviceScope.launch { + val content = callWithErrorHandling { musicService.getPlaylist(id, name) } + + if (content != null) { + if (content.getAllChild().count() > 1) + mediaItems.addPlayAllItem( + listOf(MEDIA_PLAYLIST_ITEM, id, name).joinToString("|") + ) + + // Playlist should be cached as it may contain random elements + playlistCache = content.getAllChild() + playlistCache!!.take(DISPLAY_LIMIT).map { item -> + mediaItems.add( + MediaBrowserCompat.MediaItem( + Util.getMediaDescriptionForEntry( + item, + listOf( + MEDIA_PLAYLIST_SONG_ITEM, + id, + name, + item.id + ).joinToString("|") + ), + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + ) + } + result.sendResult(mediaItems) + } + } + } + + private fun playPlaylist(id: String, name: String) { + serviceScope.launch { + if (playlistCache == null) { + // This can only happen if Android Auto cached items, but Ultrasonic has forgot them + val content = callWithErrorHandling { musicService.getPlaylist(id, name) } + playlistCache = content?.getAllChild() + } + if (playlistCache != null) playSongs(playlistCache) + } + } + + private fun playPlaylistSong(id: String, name: String, songId: String) { + serviceScope.launch { + if (playlistCache == null) { + // This can only happen if Android Auto cached items, but Ultrasonic has forgot them + val content = callWithErrorHandling { musicService.getPlaylist(id, name) } + playlistCache = content?.getAllChild() + } + val song = playlistCache?.firstOrNull { x -> x.id == songId } + if (song != null) playSong(song) + } + } + + private fun playAlbum(id: String, name: String) { + serviceScope.launch { + val songs = listSongsInMusicService(id, name) + if (songs != null) playSongs(songs.getAllChild()) + } + } + + private fun playAlbumSong(id: String, name: String, songId: String) { + serviceScope.launch { + val songs = listSongsInMusicService(id, name) + val song = songs?.getAllChild()?.firstOrNull { x -> x.id == songId } + if (song != null) playSong(song) + } + } + + private fun getPodcasts(result: Result>) { + val mediaItems: MutableList = ArrayList() + result.detach() + serviceScope.launch { + val podcasts = callWithErrorHandling { musicService.getPodcastsChannels(false) } + + podcasts?.map { podcast -> + mediaItems.add( + podcast.title ?: "", + listOf(MEDIA_PODCAST_ITEM, podcast.id).joinToString("|"), + null + ) + } + result.sendResult(mediaItems) + } + } + + private fun getPodcastEpisodes( + result: Result>, + id: String + ) { + val mediaItems: MutableList = ArrayList() + result.detach() + serviceScope.launch { + val episodes = callWithErrorHandling { musicService.getPodcastEpisodes(id) } + + if (episodes != null) { + if (episodes.getAllChild().count() > 1) + mediaItems.addPlayAllItem(listOf(MEDIA_PODCAST_ITEM, id).joinToString("|")) + + episodes.getAllChild().map { episode -> + mediaItems.add( + MediaBrowserCompat.MediaItem( + Util.getMediaDescriptionForEntry( + episode, + listOf(MEDIA_PODCAST_EPISODE_ITEM, id, episode.id) + .joinToString("|") + ), + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + ) + } + result.sendResult(mediaItems) + } + } + } + + private fun playPodcast(id: String) { + serviceScope.launch { + val episodes = callWithErrorHandling { musicService.getPodcastEpisodes(id) } + if (episodes != null) { + playSongs(episodes.getAllChild()) + } + } + } + + private fun playPodcastEpisode(id: String, episodeId: String) { + serviceScope.launch { + val episodes = callWithErrorHandling { musicService.getPodcastEpisodes(id) } + if (episodes != null) { + val selectedEpisode = episodes + .getAllChild() + .firstOrNull { episode -> episode.id == episodeId } + if (selectedEpisode != null) playSong(selectedEpisode) + } + } + } + + private fun getBookmarks(result: Result>) { + val mediaItems: MutableList = ArrayList() + result.detach() + serviceScope.launch { + val bookmarks = callWithErrorHandling { musicService.getBookmarks() } + if (bookmarks != null) { + val songs = Util.getSongsFromBookmarks(bookmarks) + + songs.getAllChild().map { song -> + mediaItems.add( + MediaBrowserCompat.MediaItem( + Util.getMediaDescriptionForEntry( + song, + listOf(MEDIA_BOOKMARK_ITEM, song.id).joinToString("|") + ), + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + ) + } + result.sendResult(mediaItems) + } + } + } + + private fun playBookmark(id: String) { + serviceScope.launch { + val bookmarks = callWithErrorHandling { musicService.getBookmarks() } + if (bookmarks != null) { + val songs = Util.getSongsFromBookmarks(bookmarks) + val song = songs.getAllChild().firstOrNull { song -> song.id == id } + if (song != null) playSong(song) + } + } + } + + private fun getShares(result: Result>) { + val mediaItems: MutableList = ArrayList() + result.detach() + + serviceScope.launch { + val shares = callWithErrorHandling { musicService.getShares(false) } + + shares?.map { share -> + mediaItems.add( + share.name ?: "", + listOf(MEDIA_SHARE_ITEM, share.id) + .joinToString("|"), + null + ) + } + result.sendResult(mediaItems) + } + } + + private fun getSongsForShare( + result: Result>, + id: String + ) { + val mediaItems: MutableList = ArrayList() + result.detach() + + serviceScope.launch { + val shares = callWithErrorHandling { musicService.getShares(false) } + + val selectedShare = shares?.firstOrNull { share -> share.id == id } + if (selectedShare != null) { + + if (selectedShare.getEntries().count() > 1) + mediaItems.addPlayAllItem(listOf(MEDIA_SHARE_ITEM, id).joinToString("|")) + + selectedShare.getEntries().map { song -> + mediaItems.add( + MediaBrowserCompat.MediaItem( + Util.getMediaDescriptionForEntry( + song, + listOf(MEDIA_SHARE_SONG_ITEM, id, song.id).joinToString("|") + ), + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + ) + } + } + result.sendResult(mediaItems) + } + } + + private fun playShare(id: String) { + serviceScope.launch { + val shares = callWithErrorHandling { musicService.getShares(false) } + val selectedShare = shares?.firstOrNull { share -> share.id == id } + if (selectedShare != null) { + playSongs(selectedShare.getEntries()) + } + } + } + + private fun playShareSong(id: String, songId: String) { + serviceScope.launch { + val shares = callWithErrorHandling { musicService.getShares(false) } + val selectedShare = shares?.firstOrNull { share -> share.id == id } + if (selectedShare != null) { + val song = selectedShare.getEntries().firstOrNull { x -> x.id == songId } + if (song != null) playSong(song) + } + } + } + + private fun getStarredSongs(result: Result>) { + val mediaItems: MutableList = ArrayList() + result.detach() + + serviceScope.launch { + val songs = listStarredSongsInMusicService() + + if (songs != null) { + if (songs.songs.count() > 1) + mediaItems.addPlayAllItem(listOf(MEDIA_SONG_STARRED_ID).joinToString("|")) + + // TODO: Paging is not implemented for songs, is it necessary at all? + val items = songs.songs.take(DISPLAY_LIMIT) + starredSongsCache = items + items.map { song -> + mediaItems.add( + MediaBrowserCompat.MediaItem( + Util.getMediaDescriptionForEntry( + song, + listOf(MEDIA_SONG_STARRED_ITEM, song.id).joinToString("|") + ), + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + ) + } + } + result.sendResult(mediaItems) + } + } + + private fun playStarredSongs() { + serviceScope.launch { + if (starredSongsCache == null) { + // This can only happen if Android Auto cached items, but Ultrasonic has forgot them + val content = listStarredSongsInMusicService() + starredSongsCache = content?.songs + } + if (starredSongsCache != null) playSongs(starredSongsCache) + } + } + + private fun playStarredSong(songId: String) { + serviceScope.launch { + if (starredSongsCache == null) { + // This can only happen if Android Auto cached items, but Ultrasonic has forgot them + val content = listStarredSongsInMusicService() + starredSongsCache = content?.songs + } + val song = starredSongsCache?.firstOrNull { x -> x.id == songId } + if (song != null) playSong(song) + } + } + + private fun getRandomSongs(result: Result>) { + val mediaItems: MutableList = ArrayList() + result.detach() + + serviceScope.launch { + val songs = callWithErrorHandling { musicService.getRandomSongs(DISPLAY_LIMIT) } + + if (songs != null) { + if (songs.getAllChild().count() > 1) + mediaItems.addPlayAllItem(listOf(MEDIA_SONG_RANDOM_ID).joinToString("|")) + + // TODO: Paging is not implemented for songs, is it necessary at all? + val items = songs.getAllChild() + randomSongsCache = items + items.map { song -> + mediaItems.add( + MediaBrowserCompat.MediaItem( + Util.getMediaDescriptionForEntry( + song, + listOf(MEDIA_SONG_RANDOM_ITEM, song.id).joinToString("|") + ), + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + ) + } + } + result.sendResult(mediaItems) + } + } + + private fun playRandomSongs() { + serviceScope.launch { + if (randomSongsCache == null) { + // This can only happen if Android Auto cached items, but Ultrasonic has forgot them + // In this case we request a new set of random songs + val content = callWithErrorHandling { musicService.getRandomSongs(DISPLAY_LIMIT) } + randomSongsCache = content?.getAllChild() + } + if (randomSongsCache != null) playSongs(randomSongsCache) + } + } + + private fun playRandomSong(songId: String) { + serviceScope.launch { + // If there is no cache, we can't play the selected song. + if (randomSongsCache != null) { + val song = randomSongsCache!!.firstOrNull { x -> x.id == songId } + if (song != null) playSong(song) + } + } + } + + private fun listSongsInMusicService(id: String, name: String): MusicDirectory? { + return if (!ActiveServerProvider.isOffline() && Util.getShouldUseId3Tags()) { + callWithErrorHandling { musicService.getAlbum(id, name, false) } + } else { + callWithErrorHandling { musicService.getMusicDirectory(id, name, false) } + } + } + + private fun listStarredSongsInMusicService(): SearchResult? { + return if (Util.getShouldUseId3Tags()) { + callWithErrorHandling { musicService.getStarred2() } + } else { + callWithErrorHandling { musicService.getStarred() } + } + } + + private fun MutableList.add( + title: String, + mediaId: String, + icon: Int?, + groupNameId: Int? = null + ) { + val builder = MediaDescriptionCompat.Builder() + builder.setTitle(title) + builder.setMediaId(mediaId) + + if (icon != null) + builder.setIconUri(Util.getUriToDrawable(applicationContext, icon)) + + if (groupNameId != null) + builder.setExtras( + Bundle().apply { + putString( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, + getString(groupNameId) + ) + } + ) + + val mediaItem = MediaBrowserCompat.MediaItem( + builder.build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + + this.add(mediaItem) + } + + private fun MutableList.add( + resId: Int, + mediaId: String, + icon: Int?, + groupNameId: Int?, + browsable: Boolean = true + ) { + val builder = MediaDescriptionCompat.Builder() + builder.setTitle(getString(resId)) + builder.setMediaId(mediaId) + + if (icon != null) + builder.setIconUri(Util.getUriToDrawable(applicationContext, icon)) + + if (groupNameId != null) + builder.setExtras( + Bundle().apply { + putString( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, + getString(groupNameId) + ) + } + ) + + val mediaItem = MediaBrowserCompat.MediaItem( + builder.build(), + if (browsable) MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + else MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + + this.add(mediaItem) + } + + private fun MutableList.addPlayAllItem( + mediaId: String, + ) { + this.add( + R.string.select_album_play_all, + mediaId, + R.drawable.ic_stat_play_dark, + null, + false + ) + } + + private fun getSectionFromName(name: String): String { + var section = name.first().uppercaseChar() + if (!section.isLetter()) section = '#' + return section.toString() + } + + private fun playSongs(songs: List?) { + mediaPlayerController.download( + songs, + save = false, + autoPlay = true, + playNext = false, + shuffle = false, + newPlaylist = true + ) + } + + private fun playSong(song: MusicDirectory.Entry) { + mediaPlayerController.download( + listOf(song), + save = false, + autoPlay = false, + playNext = true, + shuffle = false, + newPlaylist = false + ) + if (mediaPlayerController.playlistSize > 1) mediaPlayerController.next() + else mediaPlayerController.play() + } + + private fun callWithErrorHandling(function: () -> T): T? { + // TODO Implement better error handling + return try { + function() + } catch (all: Exception) { + Timber.i(all) + null + } + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadQueueSerializer.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadQueueSerializer.kt new file mode 100644 index 00000000..1cb08b32 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadQueueSerializer.kt @@ -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() + private val mediaSessionHandler by inject() + + val lock: Lock = ReentrantLock() + val setup = AtomicBoolean(false) + + private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + fun serializeDownloadQueue( + songs: Iterable, + currentPlayingIndex: Int, + currentPlayingPosition: Int + ) { + if (!setup.get()) return + + appScope.launch { + if (lock.tryLock()) { + try { + serializeDownloadQueueNow(songs, currentPlayingIndex, currentPlayingPosition) + } finally { + lock.unlock() + } + } + } + } + + fun serializeDownloadQueueNow( + songs: Iterable, + currentPlayingIndex: Int, + currentPlayingPosition: Int + ) { + val state = State() + + for (downloadFile in songs) { + state.songs.add(downloadFile.song) + } + + state.currentPlayingIndex = currentPlayingIndex + state.currentPlayingPosition = currentPlayingPosition + + Timber.i( + "Serialized currentPlayingIndex: %d, currentPlayingPosition: %d", + state.currentPlayingIndex, + state.currentPlayingPosition + ) + + FileUtil.serialize(context, state, Constants.FILENAME_DOWNLOADS_SER) + + // This is called here because the queue is usually serialized after a change + mediaSessionHandler.updateMediaSessionQueue(state.songs) + } + + fun deserializeDownloadQueue(afterDeserialized: Consumer) { + + appScope.launch { + try { + lock.lock() + deserializeDownloadQueueNow(afterDeserialized) + setup.set(true) + } finally { + lock.unlock() + } + } + } + + private fun deserializeDownloadQueueNow(afterDeserialized: Consumer) { + + val state = FileUtil.deserialize( + context, Constants.FILENAME_DOWNLOADS_SER + ) ?: return + + Timber.i( + "Deserialized currentPlayingIndex: %d, currentPlayingPosition: %d ", + state.currentPlayingIndex, + state.currentPlayingPosition + ) + + mediaSessionHandler.updateMediaSessionQueue(state.songs) + afterDeserialized.accept(state) + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt index a8eb34b0..b63a0eb9 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt @@ -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() + private val context by inject() + private val mediaSessionHandler by inject() @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 diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt index b18eb0fa..994f06e8 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt @@ -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() } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt new file mode 100644 index 00000000..3b9a1800 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt @@ -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() + private val mediaPlayerController by inject() + private val downloader by inject() + private val mediaSessionEventDistributor by inject() + + private var created = false + private var headsetEventReceiver: BroadcastReceiver? = null + private lateinit var mediaSessionEventListener: MediaSessionEventListener + + fun onCreate() { + onCreate(false, null) + } + + private fun onCreate(autoPlay: Boolean, afterCreated: Runnable?) { + + if (created) { + afterCreated?.run() + return + } + + mediaSessionEventListener = object : MediaSessionEventListener { + override fun onMediaButtonEvent(keyEvent: KeyEvent?) { + if (keyEvent != null) handleKeyEvent(keyEvent) + } + } + + mediaSessionEventDistributor.subscribe(mediaSessionEventListener) + registerHeadsetReceiver() + mediaPlayerController.onCreate() + if (autoPlay) mediaPlayerController.preload() + + downloadQueueSerializer.deserializeDownloadQueue(object : Consumer() { + override fun accept(state: State?) { + mediaPlayerController.restore( + state!!.songs, + state.currentPlayingIndex, + state.currentPlayingPosition, + autoPlay, + false + ) + + // Work-around: Serialize again, as the restore() method creates a + // serialization without current playing info. + downloadQueueSerializer.serializeDownloadQueue( + downloader.downloadList, + downloader.currentPlayingIndex, + mediaPlayerController.playerPosition + ) + afterCreated?.run() + } + }) + + CacheCleaner().clean() + created = true + Timber.i("LifecycleSupport created") + } + + fun onDestroy() { + + if (!created) return + + downloadQueueSerializer.serializeDownloadQueueNow( + downloader.downloadList, + downloader.currentPlayingIndex, + mediaPlayerController.playerPosition + ) + + mediaSessionEventDistributor.unsubscribe(mediaSessionEventListener) + + mediaPlayerController.clear(false) + applicationContext().unregisterReceiver(headsetEventReceiver) + mediaPlayerController.onDestroy() + + created = false + Timber.i("LifecycleSupport destroyed") + } + + fun receiveIntent(intent: Intent?) { + + if (intent == null) return + + val intentAction = intent.action + if (intentAction == null || intentAction.isEmpty()) return + + Timber.i("Received intent: %s", intentAction) + + if (intentAction == Constants.CMD_PROCESS_KEYCODE) { + if (intent.extras != null) { + val event = intent.extras!![Intent.EXTRA_KEY_EVENT] as KeyEvent? + event?.let { handleKeyEvent(it) } + } + } else { + handleUltrasonicIntent(intentAction) + } + } + + /** + * The Headset Intent Receiver is responsible for resuming playback when a headset is inserted + * and pausing it when it is removed. + * Unfortunately this Intent can't be registered in the AndroidManifest, so it works only + * while Ultrasonic is running. + */ + private fun registerHeadsetReceiver() { + + val sp = Util.getPreferences() + val context = applicationContext() + val spKey = context + .getString(R.string.settings_playback_resume_play_on_headphones_plug) + + headsetEventReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val extras = intent.extras ?: return + + Timber.i("Headset event for: %s", extras["name"]) + + val state = extras.getInt("state") + + if (state == 0) { + if (!mediaPlayerController.isJukeboxEnabled) { + mediaPlayerController.pause() + } + } else if (state == 1) { + if (!mediaPlayerController.isJukeboxEnabled && + sp.getBoolean( + spKey, + false + ) && mediaPlayerController.playerState === PlayerState.PAUSED + ) { + mediaPlayerController.start() + } + } + } + } + + val headsetIntentFilter: IntentFilter = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + IntentFilter(AudioManager.ACTION_HEADSET_PLUG) + } else { + IntentFilter(Intent.ACTION_HEADSET_PLUG) + } + + applicationContext().registerReceiver(headsetEventReceiver, headsetIntentFilter) + } + + @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() + } + } + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt index 3fd8f7e6..4e6c14d7 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt @@ -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() private val localMediaPlayer by inject() private val nowPlayingEventDistributor by inject() - private val mediaPlayerLifecycleSupport by inject() + private val mediaSessionEventDistributor by inject() + private val mediaSessionHandler by inject() 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() diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/ShareHandler.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/ShareHandler.kt index 4ae89551..52d2a403 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/ShareHandler.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/ShareHandler.kt @@ -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() diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionEventDistributor.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionEventDistributor.kt new file mode 100644 index 00000000..a9ecade8 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionEventDistributor.kt @@ -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 = + listOf().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) } + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionEventListener.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionEventListener.kt new file mode 100644 index 00000000..e4075248 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionEventListener.kt @@ -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?) {} +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt new file mode 100644 index 00000000..c1191b71 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt @@ -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() + private val applicationContext by inject() + + private var referenceCount: Int = 0 + private var cachedPlaylist: Iterable? = 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) { + // 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) + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt new file mode 100644 index 00000000..d59323b3 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt @@ -0,0 +1,1351 @@ +/* + * Util.kt + * Copyright (C) 2009-2021 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.util + +import android.annotation.SuppressLint +import android.app.Activity +import android.app.AlertDialog +import android.app.PendingIntent +import android.content.ContentResolver +import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.net.ConnectivityManager +import android.net.Uri +import android.net.wifi.WifiManager +import android.net.wifi.WifiManager.WifiLock +import android.os.Build +import android.os.Bundle +import android.os.Environment +import android.os.Parcelable +import android.support.v4.media.MediaDescriptionCompat +import android.text.TextUtils +import android.util.TypedValue +import android.view.Gravity +import android.view.KeyEvent +import android.view.inputmethod.InputMethodManager +import android.widget.Toast +import androidx.annotation.AnyRes +import androidx.media.utils.MediaConstants +import androidx.preference.PreferenceManager +import java.io.ByteArrayOutputStream +import java.io.Closeable +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.io.UnsupportedEncodingException +import java.security.MessageDigest +import java.text.DecimalFormat +import java.util.Locale +import java.util.concurrent.TimeUnit +import java.util.regex.Pattern +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt +import org.moire.ultrasonic.R +import org.moire.ultrasonic.app.UApp.Companion.applicationContext +import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline +import org.moire.ultrasonic.domain.Bookmark +import org.moire.ultrasonic.domain.MusicDirectory +import org.moire.ultrasonic.domain.PlayerState +import org.moire.ultrasonic.domain.RepeatMode +import org.moire.ultrasonic.domain.SearchResult +import org.moire.ultrasonic.service.DownloadFile +import timber.log.Timber + +private const val LINE_LENGTH = 60 +private const val DEGRADE_PRECISION_AFTER = 10 +private const val MINUTES_IN_HOUR = 60 +private const val KBYTE = 1024 + +/** + * Contains various utility functions + */ +@Suppress("TooManyFunctions", "LargeClass") +object Util { + + private val GIGA_BYTE_FORMAT = DecimalFormat("0.00 GB") + private val MEGA_BYTE_FORMAT = DecimalFormat("0.00 MB") + private val KILO_BYTE_FORMAT = DecimalFormat("0 KB") + private val PATTERN = Pattern.compile(":") + private var GIGA_BYTE_LOCALIZED_FORMAT: DecimalFormat? = null + private var MEGA_BYTE_LOCALIZED_FORMAT: DecimalFormat? = null + private var KILO_BYTE_LOCALIZED_FORMAT: DecimalFormat? = null + private var BYTE_LOCALIZED_FORMAT: DecimalFormat? = null + const val EVENT_META_CHANGED = "org.moire.ultrasonic.EVENT_META_CHANGED" + const val EVENT_PLAYSTATE_CHANGED = "org.moire.ultrasonic.EVENT_PLAYSTATE_CHANGED" + const val CM_AVRCP_PLAYSTATE_CHANGED = "com.android.music.playstatechanged" + const val CM_AVRCP_METADATA_CHANGED = "com.android.music.metachanged" + + // Used by hexEncode() + private val HEX_DIGITS = + charArrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f') + private var toast: Toast? = null + + // Retrieves an instance of the application Context + fun appContext(): Context { + return applicationContext() + } + + fun isScreenLitOnDownload(): Boolean { + val preferences = getPreferences() + return preferences.getBoolean( + Constants.PREFERENCES_KEY_SCREEN_LIT_ON_DOWNLOAD, + false + ) + } + + var repeatMode: RepeatMode + get() { + val preferences = getPreferences() + return RepeatMode.valueOf( + preferences.getString( + Constants.PREFERENCES_KEY_REPEAT_MODE, + RepeatMode.OFF.name + )!! + ) + } + set(repeatMode) { + val preferences = getPreferences() + val editor = preferences.edit() + editor.putString(Constants.PREFERENCES_KEY_REPEAT_MODE, repeatMode.name) + editor.apply() + } + + // After API26 foreground services must be used for music playback, + // and they must have a notification + fun isNotificationEnabled(): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) return true + val preferences = getPreferences() + return preferences.getBoolean(Constants.PREFERENCES_KEY_SHOW_NOTIFICATION, false) + } + + // After API26 foreground services must be used for music playback, + // and they must have a notification + fun isNotificationAlwaysEnabled(): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) return true + val preferences = getPreferences() + return preferences.getBoolean(Constants.PREFERENCES_KEY_ALWAYS_SHOW_NOTIFICATION, false) + } + + fun isLockScreenEnabled(): Boolean { + val preferences = getPreferences() + return preferences.getBoolean( + Constants.PREFERENCES_KEY_SHOW_LOCK_SCREEN_CONTROLS, + false + ) + } + + @JvmStatic + fun getTheme(): String? { + val preferences = getPreferences() + return preferences.getString( + Constants.PREFERENCES_KEY_THEME, + Constants.PREFERENCES_KEY_THEME_DARK + ) + } + + @JvmStatic + fun applyTheme(context: Context?) { + val theme = getTheme() + if (Constants.PREFERENCES_KEY_THEME_DARK.equals( + theme, + ignoreCase = true + ) || "fullscreen".equals(theme, ignoreCase = true) + ) { + context!!.setTheme(R.style.UltrasonicTheme) + } else if (Constants.PREFERENCES_KEY_THEME_BLACK.equals(theme, ignoreCase = true)) { + context!!.setTheme(R.style.UltrasonicTheme_Black) + } else if (Constants.PREFERENCES_KEY_THEME_LIGHT.equals( + theme, + ignoreCase = true + ) || "fullscreenlight".equals(theme, ignoreCase = true) + ) { + context!!.setTheme(R.style.UltrasonicTheme_Light) + } + } + + private fun getConnectivityManager(): ConnectivityManager { + val context = appContext() + return context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + } + + @JvmStatic + fun getMaxBitRate(): Int { + val manager = getConnectivityManager() + val networkInfo = manager.activeNetworkInfo ?: return 0 + val wifi = networkInfo.type == ConnectivityManager.TYPE_WIFI + val preferences = getPreferences() + return preferences.getString( + if (wifi) Constants.PREFERENCES_KEY_MAX_BITRATE_WIFI + else Constants.PREFERENCES_KEY_MAX_BITRATE_MOBILE, + "0" + )!!.toInt() + } + + @JvmStatic + fun getPreloadCount(): Int { + val preferences = getPreferences() + val preloadCount = + preferences.getString(Constants.PREFERENCES_KEY_PRELOAD_COUNT, "-1")!! + .toInt() + return if (preloadCount == -1) Int.MAX_VALUE else preloadCount + } + + @JvmStatic + fun getCacheSizeMB(): Int { + val preferences = getPreferences() + val cacheSize = preferences.getString( + Constants.PREFERENCES_KEY_CACHE_SIZE, + "-1" + )!!.toInt() + return if (cacheSize == -1) Int.MAX_VALUE else cacheSize + } + + @JvmStatic + fun getPreferences(): SharedPreferences = + PreferenceManager.getDefaultSharedPreferences(appContext()) + + /** + * Get the contents of an `InputStream` as a `byte[]`. + * + * + * This method buffers the input internally, so there is no need to use a + * `BufferedInputStream`. + * + * @param input the `InputStream` to read from + * @return the requested byte array + * @throws NullPointerException if the input is null + * @throws java.io.IOException if an I/O error occurs + */ + @Throws(IOException::class) + fun toByteArray(input: InputStream?): ByteArray { + val output = ByteArrayOutputStream() + copy(input!!, output) + return output.toByteArray() + } + + @Throws(IOException::class) + @Suppress("MagicNumber") + fun copy(input: InputStream, output: OutputStream): Long { + val buffer = ByteArray(KBYTE * 4) + var count: Long = 0 + var n: Int + while (-1 != input.read(buffer).also { n = it }) { + output.write(buffer, 0, n) + count += n.toLong() + } + return count + } + + @Throws(IOException::class) + fun atomicCopy(from: File, to: File) { + val tmp = File(String.format(Locale.ROOT, "%s.tmp", to.path)) + val input = FileInputStream(from) + val out = FileOutputStream(tmp) + try { + input.channel.transferTo(0, from.length(), out.channel) + out.close() + if (!tmp.renameTo(to)) { + throw IOException( + String.format(Locale.ROOT, "Failed to rename %s to %s", tmp, to) + ) + } + Timber.i("Copied %s to %s", from, to) + } catch (x: IOException) { + close(out) + delete(to) + throw x + } finally { + close(input) + close(out) + delete(tmp) + } + } + + @JvmStatic + @Throws(IOException::class) + fun renameFile(from: File, to: File) { + if (from.renameTo(to)) { + Timber.i("Renamed %s to %s", from, to) + } else { + atomicCopy(from, to) + } + } + + @JvmStatic + fun close(closeable: Closeable?) { + try { + closeable?.close() + } catch (_: Throwable) { + // Ignored + } + } + + @JvmStatic + fun delete(file: File?): Boolean { + if (file != null && file.exists()) { + if (!file.delete()) { + Timber.w("Failed to delete file %s", file) + return false + } + Timber.i("Deleted file %s", file) + } + return true + } + + @JvmStatic + @JvmOverloads + fun toast(context: Context?, messageId: Int, shortDuration: Boolean = true) { + toast(context, context!!.getString(messageId), shortDuration) + } + + @JvmStatic + fun toast(context: Context?, message: CharSequence?) { + toast(context, message, true) + } + + @JvmStatic + @SuppressLint("ShowToast") // Invalid warning + fun toast(context: Context?, message: CharSequence?, shortDuration: Boolean) { + if (toast == null) { + toast = Toast.makeText( + context, + message, + if (shortDuration) Toast.LENGTH_SHORT else Toast.LENGTH_LONG + ) + toast!!.setGravity(Gravity.CENTER, 0, 0) + } else { + toast!!.setText(message) + toast!!.duration = + if (shortDuration) Toast.LENGTH_SHORT else Toast.LENGTH_LONG + } + toast!!.show() + } + + /** + * Formats an Int to a percentage string + * For instance: + * + * * `format(99)` returns *"99 %"*. + * + * + * @param percent The percent as a range from 0 - 100 + * @return The formatted string. + */ + @Synchronized + fun formatPercentage(percent: Int): String { + return min(max(percent, 0), 100).toString() + " %" + } + + /** + * Converts a byte-count to a formatted string suitable for display to the user. + * For instance: + * + * * `format(918)` returns *"918 B"*. + * * `format(98765)` returns *"96 KB"*. + * * `format(1238476)` returns *"1.2 MB"*. + * + * This method assumes that 1 KB is 1024 bytes. + * To get a localized string, please use formatLocalizedBytes instead. + * + * @param byteCount The number of bytes. + * @return The formatted string. + */ + @JvmStatic + @Synchronized + fun formatBytes(byteCount: Long): String { + + // More than 1 GB? + if (byteCount >= KBYTE * KBYTE * KBYTE) { + return GIGA_BYTE_FORMAT.format(byteCount.toDouble() / (KBYTE * KBYTE * KBYTE)) + } + + // More than 1 MB? + if (byteCount >= KBYTE * KBYTE) { + return MEGA_BYTE_FORMAT.format(byteCount.toDouble() / (KBYTE * KBYTE)) + } + + // More than 1 KB? + return if (byteCount >= KBYTE) { + KILO_BYTE_FORMAT.format(byteCount.toDouble() / KBYTE) + } else "$byteCount B" + } + + /** + * Converts a byte-count to a formatted string suitable for display to the user. + * For instance: + * + * * `format(918)` returns *"918 B"*. + * * `format(98765)` returns *"96 KB"*. + * * `format(1238476)` returns *"1.2 MB"*. + * + * This method assumes that 1 KB is 1024 bytes. + * This version of the method returns a localized string. + * + * @param byteCount The number of bytes. + * @return The formatted string. + */ + @Synchronized + @Suppress("ReturnCount") + fun formatLocalizedBytes(byteCount: Long, context: Context): String { + + // More than 1 GB? + if (byteCount >= KBYTE * KBYTE * KBYTE) { + if (GIGA_BYTE_LOCALIZED_FORMAT == null) { + GIGA_BYTE_LOCALIZED_FORMAT = + DecimalFormat(context.resources.getString(R.string.util_bytes_format_gigabyte)) + } + return GIGA_BYTE_LOCALIZED_FORMAT!! + .format(byteCount.toDouble() / (KBYTE * KBYTE * KBYTE)) + } + + // More than 1 MB? + if (byteCount >= KBYTE * KBYTE) { + if (MEGA_BYTE_LOCALIZED_FORMAT == null) { + MEGA_BYTE_LOCALIZED_FORMAT = + DecimalFormat(context.resources.getString(R.string.util_bytes_format_megabyte)) + } + return MEGA_BYTE_LOCALIZED_FORMAT!! + .format(byteCount.toDouble() / (KBYTE * KBYTE)) + } + + // More than 1 KB? + if (byteCount >= KBYTE) { + if (KILO_BYTE_LOCALIZED_FORMAT == null) { + KILO_BYTE_LOCALIZED_FORMAT = + DecimalFormat(context.resources.getString(R.string.util_bytes_format_kilobyte)) + } + return KILO_BYTE_LOCALIZED_FORMAT!!.format(byteCount.toDouble() / KBYTE) + } + if (BYTE_LOCALIZED_FORMAT == null) { + BYTE_LOCALIZED_FORMAT = + DecimalFormat(context.resources.getString(R.string.util_bytes_format_byte)) + } + return BYTE_LOCALIZED_FORMAT!!.format(byteCount.toDouble()) + } + + fun equals(object1: Any?, object2: Any?): Boolean { + return object1 === object2 || !(object1 == null || object2 == null) && object1 == object2 + } + + /** + * Encodes the given string by using the hexadecimal representation of its UTF-8 bytes. + * + * @param s The string to encode. + * @return The encoded string. + */ + @Suppress("TooGenericExceptionThrown", "TooGenericExceptionCaught") + fun utf8HexEncode(s: String?): String? { + if (s == null) { + return null + } + val utf8: ByteArray = try { + s.toByteArray(charset(Constants.UTF_8)) + } catch (x: UnsupportedEncodingException) { + throw RuntimeException(x) + } + return hexEncode(utf8) + } + + /** + * Converts an array of bytes into an array of characters representing the hexadecimal values of each byte in order. + * The returned array will be double the length of the passed array, as it takes two characters to represent any + * given byte. + * + * @param data Bytes to convert to hexadecimal characters. + * @return A string containing hexadecimal characters. + */ + @Suppress("MagicNumber") + fun hexEncode(data: ByteArray): String { + val length = data.size + val out = CharArray(length shl 1) + var j = 0 + + // two characters form the hex value. + for (aData in data) { + out[j++] = HEX_DIGITS[0xF0 and aData.toInt() ushr 4] + out[j++] = HEX_DIGITS[0x0F and aData.toInt()] + } + return String(out) + } + + /** + * Calculates the MD5 digest and returns the value as a 32 character hex string. + * + * @param s Data to digest. + * @return MD5 digest as a hex string. + */ + @JvmStatic + @Suppress("TooGenericExceptionThrown", "TooGenericExceptionCaught") + fun md5Hex(s: String?): String? { + return if (s == null) { + null + } else try { + val md5 = MessageDigest.getInstance("MD5") + hexEncode(md5.digest(s.toByteArray(charset(Constants.UTF_8)))) + } catch (x: Exception) { + throw RuntimeException(x.message, x) + } + } + + @JvmStatic + fun getGrandparent(path: String?): String? { + // Find the top level folder, assume it is the album artist + if (path != null) { + val slashIndex = path.indexOf('/') + if (slashIndex > 0) { + return path.substring(0, slashIndex) + } + } + return null + } + + @JvmStatic + fun isNetworkConnected(): Boolean { + val manager = getConnectivityManager() + val networkInfo = manager.activeNetworkInfo + val connected = networkInfo != null && networkInfo.isConnected + val wifiConnected = connected && networkInfo!!.type == ConnectivityManager.TYPE_WIFI + val wifiRequired = isWifiRequiredForDownload() + return connected && (!wifiRequired || wifiConnected) + } + + @JvmStatic + fun isExternalStoragePresent(): Boolean = + Environment.MEDIA_MOUNTED == Environment.getExternalStorageState() + + fun isWifiRequiredForDownload(): Boolean { + val preferences = getPreferences() + return preferences.getBoolean( + Constants.PREFERENCES_KEY_WIFI_REQUIRED_FOR_DOWNLOAD, + false + ) + } + + fun shouldDisplayBitrateWithArtist(): Boolean { + val preferences = getPreferences() + return preferences.getBoolean( + Constants.PREFERENCES_KEY_DISPLAY_BITRATE_WITH_ARTIST, + true + ) + } + + @JvmStatic + fun shouldUseFolderForArtistName(): Boolean { + val preferences = getPreferences() + return preferences.getBoolean( + Constants.PREFERENCES_KEY_USE_FOLDER_FOR_ALBUM_ARTIST, + false + ) + } + + fun shouldShowTrackNumber(): Boolean { + val preferences = getPreferences() + return preferences.getBoolean(Constants.PREFERENCES_KEY_SHOW_TRACK_NUMBER, false) + } + + // The AlertDialog requires an Activity context, app context is not enough + // See https://stackoverflow.com/questions/5436822/ + fun showDialog(context: Context?, icon: Int, titleId: Int, message: String?) { + AlertDialog.Builder(context) + .setIcon(icon) + .setTitle(titleId) + .setMessage(message) + .setPositiveButton(R.string.common_ok) { + dialog: DialogInterface, + _: Int -> + dialog.dismiss() + } + .show() + } + + @JvmStatic + fun sleepQuietly(millis: Long) { + try { + Thread.sleep(millis) + } catch (x: InterruptedException) { + Timber.w(x, "Interrupted from sleep.") + } + } + + @JvmStatic + fun getDrawableFromAttribute(context: Context?, attr: Int): Drawable { + val attrs = intArrayOf(attr) + val ta = context!!.obtainStyledAttributes(attrs) + val drawableFromTheme: Drawable? = ta.getDrawable(0) + ta.recycle() + return drawableFromTheme!! + } + + fun createDrawableFromBitmap(context: Context, bitmap: Bitmap?): Drawable { + return BitmapDrawable(context.resources, bitmap) + } + + fun createBitmapFromDrawable(drawable: Drawable): Bitmap { + if (drawable is BitmapDrawable) { + return drawable.bitmap + } + val bitmap = Bitmap.createBitmap( + drawable.intrinsicWidth, + drawable.intrinsicHeight, + Bitmap.Config.ARGB_8888 + ) + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + return bitmap + } + + fun createWifiLock(tag: String?): WifiLock { + val wm = + appContext().applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + return wm.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, tag) + } + + fun getScaledHeight(height: Double, width: Double, newWidth: Int): Int { + // Try to keep correct aspect ratio of the original image, do not force a square + val aspectRatio = height / width + + // Assume the size given refers to the width of the image, so calculate the new height using + // the previously determined aspect ratio + return Math.round(newWidth * aspectRatio).toInt() + } + + fun getScaledHeight(bitmap: Bitmap, width: Int): Int { + return getScaledHeight(bitmap.height.toDouble(), bitmap.width.toDouble(), width) + } + + fun scaleBitmap(bitmap: Bitmap?, size: Int): Bitmap? { + return if (bitmap == null) null else Bitmap.createScaledBitmap( + bitmap, + size, + getScaledHeight(bitmap, size), + true + ) + } + + fun getSongsFromSearchResult(searchResult: SearchResult): MusicDirectory { + val musicDirectory = MusicDirectory() + for (entry in searchResult.songs) { + musicDirectory.addChild(entry) + } + return musicDirectory + } + + @JvmStatic + fun getSongsFromBookmarks(bookmarks: Iterable): MusicDirectory { + val musicDirectory = MusicDirectory() + var song: MusicDirectory.Entry + for (bookmark in bookmarks) { + if (bookmark == null) continue + song = bookmark.entry + song.bookmarkPosition = bookmark.position + musicDirectory.addChild(song) + } + return musicDirectory + } + + /** + * + * Broadcasts the given song info as the new song being played. + */ + fun broadcastNewTrackInfo(context: Context, song: MusicDirectory.Entry?) { + val intent = Intent(EVENT_META_CHANGED) + if (song != null) { + intent.putExtra("title", song.title) + intent.putExtra("artist", song.artist) + intent.putExtra("album", song.album) + val albumArtFile = FileUtil.getAlbumArtFile(song) + intent.putExtra("coverart", albumArtFile.absolutePath) + } else { + intent.putExtra("title", "") + intent.putExtra("artist", "") + intent.putExtra("album", "") + intent.putExtra("coverart", "") + } + context.sendBroadcast(intent) + } + + fun broadcastA2dpMetaDataChange( + context: Context, + playerPosition: Int, + currentPlaying: DownloadFile?, + listSize: Int, + id: Int + ) { + if (!shouldSendBluetoothNotifications) { + return + } + var song: MusicDirectory.Entry? = null + val avrcpIntent = Intent(CM_AVRCP_METADATA_CHANGED) + if (currentPlaying != null) song = currentPlaying.song + + if (song == null) { + avrcpIntent.putExtra("track", "") + avrcpIntent.putExtra("track_name", "") + avrcpIntent.putExtra("artist", "") + avrcpIntent.putExtra("artist_name", "") + avrcpIntent.putExtra("album", "") + avrcpIntent.putExtra("album_name", "") + avrcpIntent.putExtra("album_artist", "") + avrcpIntent.putExtra("album_artist_name", "") + + if (getShouldSendBluetoothAlbumArt()) { + avrcpIntent.putExtra("coverart", null as Parcelable?) + avrcpIntent.putExtra("cover", null as Parcelable?) + } + + avrcpIntent.putExtra("ListSize", 0.toLong()) + avrcpIntent.putExtra("id", 0.toLong()) + avrcpIntent.putExtra("duration", 0.toLong()) + avrcpIntent.putExtra("position", 0.toLong()) + } else { + val title = song.title + val artist = song.artist + val album = song.album + val duration = song.duration + + avrcpIntent.putExtra("track", title) + avrcpIntent.putExtra("track_name", title) + avrcpIntent.putExtra("artist", artist) + avrcpIntent.putExtra("artist_name", artist) + avrcpIntent.putExtra("album", album) + avrcpIntent.putExtra("album_name", album) + avrcpIntent.putExtra("album_artist", artist) + avrcpIntent.putExtra("album_artist_name", artist) + + if (getShouldSendBluetoothAlbumArt()) { + val albumArtFile = FileUtil.getAlbumArtFile(song) + avrcpIntent.putExtra("coverart", albumArtFile.absolutePath) + avrcpIntent.putExtra("cover", albumArtFile.absolutePath) + } + + avrcpIntent.putExtra("position", playerPosition.toLong()) + avrcpIntent.putExtra("id", id.toLong()) + avrcpIntent.putExtra("ListSize", listSize.toLong()) + + if (duration != null) { + avrcpIntent.putExtra("duration", duration.toLong()) + } + } + context.sendBroadcast(avrcpIntent) + } + + @Suppress("LongParameterList") + fun broadcastA2dpPlayStatusChange( + context: Context, + state: PlayerState?, + newSong: MusicDirectory.Entry?, + listSize: Int, + id: Int, + playerPosition: Int + ) { + if (!shouldSendBluetoothNotifications) { + return + } + if (newSong != null) { + val avrcpIntent = Intent(CM_AVRCP_PLAYSTATE_CHANGED) + + val title = newSong.title + val artist = newSong.artist + val album = newSong.album + val duration = newSong.duration + + avrcpIntent.putExtra("track", title) + avrcpIntent.putExtra("track_name", title) + avrcpIntent.putExtra("artist", artist) + avrcpIntent.putExtra("artist_name", artist) + avrcpIntent.putExtra("album", album) + avrcpIntent.putExtra("album_name", album) + avrcpIntent.putExtra("album_artist", artist) + avrcpIntent.putExtra("album_artist_name", artist) + + if (getShouldSendBluetoothAlbumArt()) { + val albumArtFile = FileUtil.getAlbumArtFile(newSong) + avrcpIntent.putExtra("coverart", albumArtFile.absolutePath) + avrcpIntent.putExtra("cover", albumArtFile.absolutePath) + } + + avrcpIntent.putExtra("position", playerPosition.toLong()) + avrcpIntent.putExtra("id", id.toLong()) + avrcpIntent.putExtra("ListSize", listSize.toLong()) + + if (duration != null) { + avrcpIntent.putExtra("duration", duration.toLong()) + } + + when (state) { + PlayerState.STARTED -> avrcpIntent.putExtra("playing", true) + PlayerState.STOPPED, PlayerState.PAUSED, + PlayerState.COMPLETED -> avrcpIntent.putExtra( + "playing", + false + ) + else -> return // No need to broadcast. + } + + context.sendBroadcast(avrcpIntent) + } + } + + /** + * + * Broadcasts the given player state as the one being set. + */ + fun broadcastPlaybackStatusChange(context: Context, state: PlayerState?) { + val intent = Intent(EVENT_PLAYSTATE_CHANGED) + when (state) { + PlayerState.STARTED -> intent.putExtra("state", "play") + PlayerState.STOPPED -> intent.putExtra("state", "stop") + PlayerState.PAUSED -> intent.putExtra("state", "pause") + PlayerState.COMPLETED -> intent.putExtra("state", "complete") + else -> return // No need to broadcast. + } + context.sendBroadcast(intent) + } + + @JvmStatic + @Suppress("MagicNumber") + fun getNotificationImageSize(context: Context): Int { + val metrics = context.resources.displayMetrics + val imageSizeLarge = + min(metrics.widthPixels, metrics.heightPixels).toFloat().roundToInt() + return when { + imageSizeLarge <= 480 -> { + 64 + } + imageSizeLarge <= 768 -> 128 + else -> 256 + } + } + + @Suppress("MagicNumber") + fun getAlbumImageSize(context: Context?): Int { + val metrics = context!!.resources.displayMetrics + val imageSizeLarge = + min(metrics.widthPixels, metrics.heightPixels).toFloat().roundToInt() + return when { + imageSizeLarge <= 480 -> { + 128 + } + imageSizeLarge <= 768 -> 256 + else -> 512 + } + } + + fun getMinDisplayMetric(): Int { + val metrics = appContext().resources.displayMetrics + return Math.min(metrics.widthPixels, metrics.heightPixels) + } + + fun getMaxDisplayMetric(): Int { + val metrics = appContext().resources.displayMetrics + return Math.max(metrics.widthPixels, metrics.heightPixels) + } + + fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int { + // Raw height and width of image + val height = options.outHeight + val width = options.outWidth + var inSampleSize = 1 + if (height > reqHeight || width > reqWidth) { + + // Calculate ratios of height and width to requested height and + // width + val heightRatio = Math.round(height.toFloat() / reqHeight.toFloat()) + val widthRatio = Math.round(width.toFloat() / reqWidth.toFloat()) + + // Choose the smallest ratio as inSampleSize value, this will + // guarantee + // a final image with both dimensions larger than or equal to the + // requested height and width. + inSampleSize = Math.min(heightRatio, widthRatio) + } + return inSampleSize + } + + @JvmStatic + fun getDefaultAlbums(): Int { + val preferences = getPreferences() + return preferences.getString(Constants.PREFERENCES_KEY_DEFAULT_ALBUMS, "5")!! + .toInt() + } + + @JvmStatic + fun getMaxAlbums(): Int { + val preferences = getPreferences() + return preferences.getString(Constants.PREFERENCES_KEY_MAX_ALBUMS, "20")!! + .toInt() + } + + @JvmStatic + fun getDefaultSongs(): Int { + val preferences = getPreferences() + return preferences.getString(Constants.PREFERENCES_KEY_DEFAULT_SONGS, "10")!! + .toInt() + } + + @JvmStatic + fun getMaxSongs(): Int { + val preferences = getPreferences() + return preferences.getString(Constants.PREFERENCES_KEY_MAX_SONGS, "25")!! + .toInt() + } + + @JvmStatic + fun getMaxArtists(): Int { + val preferences = getPreferences() + return preferences.getString(Constants.PREFERENCES_KEY_MAX_ARTISTS, "10")!! + .toInt() + } + + @JvmStatic + fun getDefaultArtists(): Int { + val preferences = getPreferences() + return preferences.getString(Constants.PREFERENCES_KEY_DEFAULT_ARTISTS, "3")!! + .toInt() + } + + @JvmStatic + fun getBufferLength(): Int { + val preferences = getPreferences() + return preferences.getString(Constants.PREFERENCES_KEY_BUFFER_LENGTH, "5")!! + .toInt() + } + + @JvmStatic + fun getIncrementTime(): Int { + val preferences = getPreferences() + return preferences.getString(Constants.PREFERENCES_KEY_INCREMENT_TIME, "5")!! + .toInt() + } + + @JvmStatic + fun getMediaButtonsEnabled(): Boolean { + val preferences = getPreferences() + return preferences.getBoolean(Constants.PREFERENCES_KEY_MEDIA_BUTTONS, true) + } + + @JvmStatic + fun getShowNowPlayingPreference(): Boolean { + val preferences = getPreferences() + return preferences.getBoolean(Constants.PREFERENCES_KEY_SHOW_NOW_PLAYING, true) + } + + @JvmStatic + fun getGaplessPlaybackPreference(): Boolean { + val preferences = getPreferences() + return preferences.getBoolean(Constants.PREFERENCES_KEY_GAPLESS_PLAYBACK, false) + } + + @JvmStatic + fun getShouldTransitionOnPlaybackPreference(): Boolean { + val preferences = getPreferences() + return preferences.getBoolean(Constants.PREFERENCES_KEY_DOWNLOAD_TRANSITION, true) + } + + @JvmStatic + fun getShouldUseId3Tags(): Boolean { + val preferences = getPreferences() + return preferences.getBoolean(Constants.PREFERENCES_KEY_ID3_TAGS, false) + } + + fun getShouldShowArtistPicture(): Boolean { + val preferences = getPreferences() + val isOffline = isOffline() + val isId3Enabled = preferences.getBoolean(Constants.PREFERENCES_KEY_ID3_TAGS, false) + val shouldShowArtistPicture = + preferences.getBoolean(Constants.PREFERENCES_KEY_SHOW_ARTIST_PICTURE, false) + return !isOffline && isId3Enabled && shouldShowArtistPicture + } + + @JvmStatic + fun getChatRefreshInterval(): Int { + val preferences = getPreferences() + return preferences.getString( + Constants.PREFERENCES_KEY_CHAT_REFRESH_INTERVAL, + "5000" + )!!.toInt() + } + + fun getDirectoryCacheTime(): Int { + val preferences = getPreferences() + return preferences.getString( + Constants.PREFERENCES_KEY_DIRECTORY_CACHE_TIME, + "300" + )!!.toInt() + } + + @JvmStatic + fun isNullOrWhiteSpace(string: String?): Boolean { + return string == null || string.isEmpty() || string.trim { it <= ' ' }.isEmpty() + } + + fun getShouldClearPlaylist(): Boolean { + val preferences = getPreferences() + return preferences.getBoolean(Constants.PREFERENCES_KEY_CLEAR_PLAYLIST, false) + } + + fun getShouldSortByDisc(): Boolean { + val preferences = getPreferences() + return preferences.getBoolean(Constants.PREFERENCES_KEY_DISC_SORT, false) + } + + fun getShouldClearBookmark(): Boolean { + val preferences = getPreferences() + return preferences.getBoolean(Constants.PREFERENCES_KEY_CLEAR_BOOKMARK, false) + } + + fun getSingleButtonPlayPause(): Boolean { + val preferences = getPreferences() + return preferences.getBoolean(Constants.PREFERENCES_KEY_SINGLE_BUTTON_PLAY_PAUSE, false) + } + + @JvmOverloads + fun formatTotalDuration(totalDuration: Long, inMilliseconds: Boolean = false): String { + var millis = totalDuration + if (!inMilliseconds) { + millis = totalDuration * 1000 + } + val hours = TimeUnit.MILLISECONDS.toHours(millis) + val minutes = TimeUnit.MILLISECONDS.toMinutes(millis) - TimeUnit.HOURS.toMinutes(hours) + val seconds = TimeUnit.MILLISECONDS.toSeconds(millis) - + TimeUnit.MINUTES.toSeconds(hours * MINUTES_IN_HOUR + minutes) + + return when { + hours >= DEGRADE_PRECISION_AFTER -> { + String.format( + Locale.getDefault(), + "%02d:%02d:%02d", + hours, + minutes, + seconds + ) + } + hours > 0 -> { + String.format(Locale.getDefault(), "%d:%02d:%02d", hours, minutes, seconds) + } + minutes >= DEGRADE_PRECISION_AFTER -> { + String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds) + } + minutes > 0 -> String.format( + Locale.getDefault(), + "%d:%02d", + minutes, + seconds + ) + else -> String.format(Locale.getDefault(), "0:%02d", seconds) + } + } + + @JvmStatic + fun getVersionName(context: Context): String? { + var versionName: String? = null + val pm = context.packageManager + if (pm != null) { + val packageName = context.packageName + try { + versionName = pm.getPackageInfo(packageName, 0).versionName + } catch (ignored: PackageManager.NameNotFoundException) { + } + } + return versionName + } + + fun getVersionCode(context: Context): Int { + var versionCode = 0 + val pm = context.packageManager + if (pm != null) { + val packageName = context.packageName + try { + versionCode = pm.getPackageInfo(packageName, 0).versionCode + } catch (ignored: PackageManager.NameNotFoundException) { + } + } + return versionCode + } + + // Inverted for readability + val shouldSendBluetoothNotifications: Boolean + get() { + val preferences = getPreferences() + return preferences.getBoolean( + Constants.PREFERENCES_KEY_SEND_BLUETOOTH_NOTIFICATIONS, + true + ) + } + + fun getShouldSendBluetoothAlbumArt(): Boolean { + val preferences = getPreferences() + return preferences.getBoolean(Constants.PREFERENCES_KEY_SEND_BLUETOOTH_ALBUM_ART, true) + } + + @JvmStatic + fun getViewRefreshInterval(): Int { + val preferences = getPreferences() + return preferences.getString(Constants.PREFERENCES_KEY_VIEW_REFRESH, "1000")!! + .toInt() + } + + var shouldAskForShareDetails: Boolean + get() { + val preferences = getPreferences() + return preferences.getBoolean(Constants.PREFERENCES_KEY_ASK_FOR_SHARE_DETAILS, true) + } + set(shouldAskForShareDetails) { + val preferences = getPreferences() + val editor = preferences.edit() + editor.putBoolean( + Constants.PREFERENCES_KEY_ASK_FOR_SHARE_DETAILS, + shouldAskForShareDetails + ) + editor.apply() + } + + var defaultShareDescription: String? + get() { + val preferences = getPreferences() + return preferences.getString(Constants.PREFERENCES_KEY_DEFAULT_SHARE_DESCRIPTION, "") + } + set(defaultShareDescription) { + val preferences = getPreferences() + val editor = preferences.edit() + editor.putString( + Constants.PREFERENCES_KEY_DEFAULT_SHARE_DESCRIPTION, + defaultShareDescription + ) + editor.apply() + } + + @JvmStatic + fun getShareGreeting(): String? { + val preferences = getPreferences() + val context = appContext() + val defaultVal = String.format( + context.resources.getString(R.string.share_default_greeting), + context.resources.getString(R.string.common_appname) + ) + return preferences.getString( + Constants.PREFERENCES_KEY_DEFAULT_SHARE_GREETING, + defaultVal + ) + } + + var defaultShareExpiration: String + get() { + val preferences = getPreferences() + return preferences.getString(Constants.PREFERENCES_KEY_DEFAULT_SHARE_EXPIRATION, "0")!! + } + set(defaultShareExpiration) { + val preferences = getPreferences() + val editor = preferences.edit() + editor.putString( + Constants.PREFERENCES_KEY_DEFAULT_SHARE_EXPIRATION, + defaultShareExpiration + ) + editor.apply() + } + + fun getDefaultShareExpirationInMillis(context: Context?): Long { + val preferences = getPreferences() + val preference = + preferences.getString(Constants.PREFERENCES_KEY_DEFAULT_SHARE_EXPIRATION, "0")!! + val split = PATTERN.split(preference) + if (split.size == 2) { + val timeSpanAmount = split[0].toInt() + val timeSpanType = split[1] + val timeSpan = TimeSpanPicker.calculateTimeSpan(context, timeSpanType, timeSpanAmount) + return timeSpan.totalMilliseconds + } + return 0 + } + + fun getShouldShowAllSongsByArtist(): Boolean { + val preferences = getPreferences() + return preferences.getBoolean(Constants.PREFERENCES_KEY_SHOW_ALL_SONGS_BY_ARTIST, false) + } + + @JvmStatic + fun scanMedia(file: File?) { + val uri = Uri.fromFile(file) + val scanFileIntent = Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, uri) + appContext().sendBroadcast(scanFileIntent) + } + + fun getResourceFromAttribute(context: Context, resId: Int): Int { + val typedValue = TypedValue() + val theme = context.theme + theme.resolveAttribute(resId, typedValue, true) + return typedValue.resourceId + } + + fun isFirstRun(): Boolean { + val preferences = getPreferences() + val firstExecuted = + preferences.getBoolean(Constants.PREFERENCES_KEY_FIRST_RUN_EXECUTED, false) + if (firstExecuted) return false + val editor = preferences.edit() + editor.putBoolean(Constants.PREFERENCES_KEY_FIRST_RUN_EXECUTED, true) + editor.apply() + return true + } + + @JvmStatic + fun getResumeOnBluetoothDevice(): Int { + val preferences = getPreferences() + return preferences.getInt( + Constants.PREFERENCES_KEY_RESUME_ON_BLUETOOTH_DEVICE, + Constants.PREFERENCE_VALUE_DISABLED + ) + } + + @JvmStatic + fun getPauseOnBluetoothDevice(): Int { + val preferences = getPreferences() + return preferences.getInt( + Constants.PREFERENCES_KEY_PAUSE_ON_BLUETOOTH_DEVICE, + Constants.PREFERENCE_VALUE_A2DP + ) + } + + fun getDebugLogToFile(): Boolean { + val preferences = getPreferences() + return preferences.getBoolean(Constants.PREFERENCES_KEY_DEBUG_LOG_TO_FILE, false) + } + + fun hideKeyboard(activity: Activity?) { + val inputManager = + activity!!.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + val currentFocusedView = activity.currentFocus + if (currentFocusedView != null) { + inputManager.hideSoftInputFromWindow( + currentFocusedView.windowToken, + InputMethodManager.HIDE_NOT_ALWAYS + ) + } + } + + fun getUriToDrawable(context: Context, @AnyRes drawableId: Int): Uri { + return Uri.parse( + ContentResolver.SCHEME_ANDROID_RESOURCE + + "://" + context.resources.getResourcePackageName(drawableId) + + '/' + context.resources.getResourceTypeName(drawableId) + + '/' + context.resources.getResourceEntryName(drawableId) + ) + } + + @Suppress("ComplexMethod", "LongMethod") + fun getMediaDescriptionForEntry( + song: MusicDirectory.Entry, + mediaId: String? = null, + groupNameId: Int? = null + ): MediaDescriptionCompat { + + val descriptionBuilder = MediaDescriptionCompat.Builder() + val artist = StringBuilder(LINE_LENGTH) + var bitRate: String? = null + + val duration = song.duration + if (duration != null) { + artist.append( + String.format(Locale.ROOT, "%s ", formatTotalDuration(duration.toLong())) + ) + } + + if (song.bitRate != null && song.bitRate!! > 0) + bitRate = String.format( + appContext().getString(R.string.song_details_kbps), song.bitRate + ) + + val fileFormat: String? + val suffix = song.suffix + val transcodedSuffix = song.transcodedSuffix + + fileFormat = if ( + TextUtils.isEmpty(transcodedSuffix) || transcodedSuffix == suffix || song.isVideo + ) suffix else String.format(Locale.ROOT, "%s > %s", suffix, transcodedSuffix) + + val artistName = song.artist + + if (artistName != null) { + if (shouldDisplayBitrateWithArtist() && ( + !bitRate.isNullOrBlank() || !fileFormat.isNullOrBlank() + ) + ) { + artist.append(artistName).append(" (").append( + String.format( + appContext().getString(R.string.song_details_all), + if (bitRate == null) "" + else String.format(Locale.ROOT, "%s ", bitRate), + fileFormat + ) + ).append(')') + } else { + artist.append(artistName) + } + } + + val trackNumber = song.track ?: 0 + + val title = StringBuilder(LINE_LENGTH) + if (shouldShowTrackNumber() && trackNumber > 0) + title.append(String.format(Locale.ROOT, "%02d - ", trackNumber)) + + title.append(song.title) + + if (song.isVideo && shouldDisplayBitrateWithArtist()) { + title.append(" (").append( + String.format( + appContext().getString(R.string.song_details_all), + if (bitRate == null) "" + else String.format(Locale.ROOT, "%s ", bitRate), + fileFormat + ) + ).append(')') + } + + if (groupNameId != null) + descriptionBuilder.setExtras( + Bundle().apply { + putString( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, + appContext().getString(groupNameId) + ) + } + ) + + descriptionBuilder.setTitle(title) + descriptionBuilder.setSubtitle(artist) + descriptionBuilder.setMediaId(mediaId) + + return descriptionBuilder.build() + } + + 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) + } +} diff --git a/ultrasonic/src/main/res/drawable/ic_artist.xml b/ultrasonic/src/main/res/drawable/ic_artist.xml new file mode 100644 index 00000000..c3daf609 --- /dev/null +++ b/ultrasonic/src/main/res/drawable/ic_artist.xml @@ -0,0 +1,9 @@ + + + diff --git a/ultrasonic/src/main/res/drawable/ic_library.xml b/ultrasonic/src/main/res/drawable/ic_library.xml new file mode 100644 index 00000000..6981f924 --- /dev/null +++ b/ultrasonic/src/main/res/drawable/ic_library.xml @@ -0,0 +1,9 @@ + + + diff --git a/ultrasonic/src/main/res/xml/automotive_app_desc.xml b/ultrasonic/src/main/res/xml/automotive_app_desc.xml new file mode 100644 index 00000000..0f485746 --- /dev/null +++ b/ultrasonic/src/main/res/xml/automotive_app_desc.xml @@ -0,0 +1,3 @@ + + +