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 7d60601a..00000000
--- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/Util.java
+++ /dev/null
@@ -1,1353 +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);
- }
-
- 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/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
index 1a9f0534..562c26a3 100644
--- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt
+++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt
@@ -7,26 +7,65 @@ 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.data.ActiveServerProvider
+import org.moire.ultrasonic.domain.MusicDirectory
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
+const val MEDIA_ROOT_ID = "MEDIA_ROOT_ID"
+const val MEDIA_ALBUM_ID = "MEDIA_ALBUM_ID"
+const val MEDIA_ALBUM_NEWEST_ID = "MEDIA_ALBUM_NEWEST_ID"
+const val MEDIA_ALBUM_RECENT_ID = "MEDIA_ALBUM_RECENT_ID"
+const val MEDIA_ALBUM_FREQUENT_ID = "MEDIA_ALBUM_FREQUENT_ID"
+const val MEDIA_ALBUM_RANDOM_ID = "MEDIA_ALBUM_RANDOM_ID"
+const val MEDIA_ALBUM_STARRED_ID = "MEDIA_ALBUM_STARRED_ID"
+const val MEDIA_SONG_RANDOM_ID = "MEDIA_SONG_RANDOM_ID"
+const val MEDIA_SONG_STARRED_ID = "MEDIA_SONG_STARRED_ID"
+const val MEDIA_ARTIST_ID = "MEDIA_ARTIST_ID"
+const val MEDIA_LIBRARY_ID = "MEDIA_LIBRARY_ID"
+const val MEDIA_PLAYLIST_ID = "MEDIA_PLAYLIST_ID"
+const val MEDIA_SHARE_ID = "MEDIA_SHARE_ID"
+const val MEDIA_BOOKMARK_ID = "MEDIA_BOOKMARK_ID"
+const val MEDIA_PODCAST_ID = "MEDIA_PODCAST_ID"
+const val MEDIA_ALBUM_ITEM = "MEDIA_ALBUM_ITEM"
+const val MEDIA_PLAYLIST_SONG_ITEM = "MEDIA_PLAYLIST_SONG_ITEM"
+const val MEDIA_PLAYLIST_ITEM = "MEDIA_ALBUM_ITEM"
+const val MEDIA_ARTIST_ITEM = "MEDIA_ARTIST_ITEM"
+const val MEDIA_ARTIST_SECTION = "MEDIA_ARTIST_SECTION"
-const val MY_MEDIA_ROOT_ID = "MY_MEDIA_ROOT_ID"
-const val MY_MEDIA_ALBUM_ID = "MY_MEDIA_ALBUM_ID"
-const val MY_MEDIA_ARTIST_ID = "MY_MEDIA_ARTIST_ID"
-const val MY_MEDIA_ALBUM_ITEM = "MY_MEDIA_ALBUM_ITEM"
-const val MY_MEDIA_LIBRARY_ID = "MY_MEDIA_LIBRARY_ID"
-const val MY_MEDIA_PLAYLIST_ID = "MY_MEDIA_PLAYLIST_ID"
+// Currently the display limit for long lists is 100 items
+const val displayLimit = 100
+/**
+ * MediaBrowserService implementation for e.g. Android Auto
+ */
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 by lazy { MusicServiceFactory.getMusicService() }
+
+ private val serviceJob = Job()
+ private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
+
+ private var playlistCache: List? = null
+
+ private val isOffline get() = ActiveServerProvider.isOffline()
+ private val useId3Tags get() = Util.getShouldUseId3Tags()
+ private val musicFolderId get() = activeServerProvider.getActiveServer().musicFolderId
override fun onCreate() {
super.onCreate()
@@ -39,7 +78,15 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
}
override fun onPlayFromMediaIdRequested(mediaId: String?, extras: Bundle?) {
- // TODO implement
+ 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])
+ }
}
override fun onPlayFromSearchRequested(query: String?, extras: Bundle?) {
@@ -65,6 +112,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
super.onDestroy()
mediaSessionEventDistributor.unsubscribe(mediaSessionEventListener)
mediaSessionHandler.release()
+ serviceJob.cancel()
Timber.i("AutoMediaBrowserService onDestroy finished")
}
@@ -73,20 +121,8 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
clientPackageName: String,
clientUid: Int,
rootHints: Bundle?
- ): BrowserRoot? {
- Timber.d("AutoMediaBrowserService onGetRoot called")
-
- // TODO: The number of horizontal items available on the Andoid Auto screen. Check and handle.
- val maximumRootChildLimit = rootHints!!.getInt(
- MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT,
- 4
- )
-
- // TODO: The type of the horizontal items children on the Android Auto screen. Check and handle.
- val supportedRootChildFlags = rootHints!!.getInt(
- MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_SUPPORTED_FLAGS,
- MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
- )
+ ): BrowserRoot {
+ Timber.d("AutoMediaBrowserService onGetRoot called. clientPackageName: %s; clientUid: %d", clientPackageName, clientUid)
val extras = Bundle()
extras.putInt(
@@ -96,19 +132,37 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM)
- return BrowserRoot(MY_MEDIA_ROOT_ID, extras)
+ return BrowserRoot(MEDIA_ROOT_ID, extras)
}
override fun onLoadChildren(
parentId: String,
result: Result>
) {
- Timber.d("AutoMediaBrowserService onLoadChildren called")
+ Timber.d("AutoMediaBrowserService onLoadChildren called. ParentId: %s", parentId)
- if (parentId == MY_MEDIA_ROOT_ID) {
- return getRootItems(result)
- } else {
- return getAlbumLists(result)
+ 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)
+ MEDIA_PLAYLIST_ID -> return getPlaylists(result)
+ MEDIA_ALBUM_FREQUENT_ID -> return getFrequentAlbums(result)
+ MEDIA_ALBUM_NEWEST_ID -> return getNewestAlbums(result)
+ MEDIA_ALBUM_RECENT_ID -> return getRecentAlbums(result)
+ MEDIA_ALBUM_RANDOM_ID -> return getRandomAlbums(result)
+ MEDIA_ALBUM_STARRED_ID -> return getStarredAlbums(result)
+ 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 getAlbums(result, parentIdParts[1])
+ else -> result.sendResult(mutableListOf())
}
}
@@ -118,70 +172,361 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() {
result: Result>
) {
super.onSearch(query, extras, result)
+ // TODO implement
}
private fun getRootItems(result: Result>) {
val mediaItems: MutableList = ArrayList()
- // TODO implement this with proper texts, icons, etc
mediaItems.add(
- MediaBrowserCompat.MediaItem(
- MediaDescriptionCompat.Builder()
- .setTitle("Library")
- .setMediaId(MY_MEDIA_LIBRARY_ID)
- .build(),
- MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
- )
+ R.string.music_library_label,
+ MEDIA_LIBRARY_ID,
+ R.drawable.ic_library,
+ null
)
mediaItems.add(
- MediaBrowserCompat.MediaItem(
- MediaDescriptionCompat.Builder()
- .setTitle("Artists")
- .setMediaId(MY_MEDIA_ARTIST_ID)
- .build(),
- MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
- )
+ R.string.main_artists_title,
+ MEDIA_ARTIST_ID,
+ R.drawable.ic_artist,
+ null
)
mediaItems.add(
- MediaBrowserCompat.MediaItem(
- MediaDescriptionCompat.Builder()
- .setTitle("Albums")
- .setMediaId(MY_MEDIA_ALBUM_ID)
- .build(),
- MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
- )
+ R.string.main_albums_title,
+ MEDIA_ALBUM_ID,
+ R.drawable.ic_menu_browse_dark,
+ null
)
mediaItems.add(
- MediaBrowserCompat.MediaItem(
- MediaDescriptionCompat.Builder()
- .setTitle("Playlists")
- .setMediaId(MY_MEDIA_PLAYLIST_ID)
- .build(),
- MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
- )
+ R.string.playlist_label,
+ MEDIA_PLAYLIST_ID,
+ R.drawable.ic_menu_playlists_dark,
+ null
)
result.sendResult(mediaItems)
}
- private fun getAlbumLists(result: Result>) {
+ private fun getLibrary(result: Result>) {
val mediaItems: MutableList = ArrayList()
- val description = MediaDescriptionCompat.Builder()
- .setTitle("Test")
- .setMediaId(MY_MEDIA_ALBUM_ITEM + 1)
- .build()
+ // Songs
+ mediaItems.add(
+ R.string.main_songs_random,
+ MEDIA_SONG_RANDOM_ID,
+ null,
+ R.string.main_songs_title
+ )
mediaItems.add(
- MediaBrowserCompat.MediaItem(
- description,
- MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
- )
+ 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 {
+ var artists = if (!isOffline && useId3Tags) {
+ // TODO this list can be big so we're not refreshing.
+ // Maybe a refresh menu item can be added
+ musicService.getArtists(false)
+ } else {
+ musicService.getIndexes(musicFolderId, false)
+ }
+
+ 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() > displayLimit) {
+ 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(MEDIA_ARTIST_ITEM, artist.id).joinToString("|"),
+ null
+ )
+ }
+ }
+ result.sendResult(mediaItems)
+ }
+ }
+
+ private fun getAlbums(
+ result: Result>,
+ artistId: String? = null
+ ) {
+ val mediaItems: MutableList = ArrayList()
+ result.detach()
+ result.sendResult(mediaItems)
+ }
+
+ private fun getPlaylists(result: Result>) {
+ val mediaItems: MutableList = ArrayList()
+ result.detach()
+
+ serviceScope.launch {
+ val playlists = 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 = musicService.getPlaylist(id, name)
+
+ mediaItems.add(
+ R.string.select_album_play_all,
+ listOf(MEDIA_PLAYLIST_ITEM, id, name).joinToString("|"),
+ R.drawable.ic_stat_play_dark,
+ null,
+ false
+ )
+
+ // Playlist should be cached as it may contain random elements
+ playlistCache = content.getAllChild()
+ playlistCache!!.take(displayLimit).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 = musicService.getPlaylist(id, name)
+ playlistCache = content.getAllChild()
+ }
+ mediaPlayerController.download(
+ playlistCache,
+ save = false,
+ autoPlay = true,
+ playNext = false,
+ shuffle = false,
+ newPlaylist = true
+ )
+ }
+ }
+
+ 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 = musicService.getPlaylist(id, name)
+ playlistCache = content.getAllChild()
+ }
+ val song = playlistCache!!.firstOrNull{x -> x.id == songId}
+ if (song != null) {
+ mediaPlayerController.download(
+ listOf(song),
+ save = false,
+ autoPlay = false,
+ playNext = true,
+ shuffle = false,
+ newPlaylist = false
+ )
+ mediaPlayerController.next()
+ }
+ }
+ }
+
+ private fun getPodcasts(result: Result>) {
+ val mediaItems: MutableList = ArrayList()
+ result.detach()
+ result.sendResult(mediaItems)
+ }
+
+ private fun getBookmarks(result: Result>) {
+ val mediaItems: MutableList = ArrayList()
+ result.detach()
+ result.sendResult(mediaItems)
+ }
+
+ private fun getShares(result: Result>) {
+ val mediaItems: MutableList = ArrayList()
+ result.detach()
+ result.sendResult(mediaItems)
+ }
+
+ private fun getStarredSongs(result: Result>) {
+ val mediaItems: MutableList = ArrayList()
+ result.detach()
+ result.sendResult(mediaItems)
+ }
+
+ private fun getRandomSongs(result: Result>) {
+ val mediaItems: MutableList = ArrayList()
+ result.detach()
+ result.sendResult(mediaItems)
+ }
+
+ private fun getStarredAlbums(result: Result>) {
+ val mediaItems: MutableList = ArrayList()
+ result.detach()
+ result.sendResult(mediaItems)
+ }
+
+ private fun getRandomAlbums(result: Result>) {
+ val mediaItems: MutableList = ArrayList()
+ result.detach()
+ result.sendResult(mediaItems)
+ }
+
+ private fun getRecentAlbums(result: Result>) {
+ val mediaItems: MutableList = ArrayList()
+ result.detach()
+ result.sendResult(mediaItems)
+ }
+
+ private fun getNewestAlbums(result: Result>) {
+ val mediaItems: MutableList = ArrayList()
+ result.detach()
+ result.sendResult(mediaItems)
+ }
+
+ private fun getFrequentAlbums(result: Result>) {
+ val mediaItems: MutableList = ArrayList()
+ result.detach()
+ result.sendResult(mediaItems)
+ }
+
+ private fun MutableList.add(
+ title: String,
+ mediaId: String,
+ icon: Int?,
+ ) {
+ val builder = MediaDescriptionCompat.Builder()
+ builder.setTitle(title)
+ builder.setMediaId(mediaId)
+
+ if (icon != null)
+ builder.setIconUri(Util.getUriToDrawable(applicationContext, icon))
+
+ 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 getSectionFromName(name: String): String {
+ var section = name.first().uppercaseChar()
+ if (!section.isLetter()) section = '#'
+ return section.toString()
+ }
}
\ No newline at end of file
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/MediaPlayerService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt
index 04b3ba4e..032e6dbd 100644
--- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt
+++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt
@@ -65,7 +65,7 @@ class MediaPlayerService : Service() {
private lateinit var mediaSessionEventListener: MediaSessionEventListener
private val repeatMode: RepeatMode
- get() = Util.getRepeatMode()
+ get() = Util.repeatMode
override fun onBind(intent: Intent): IBinder {
return binder
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/MediaSessionHandler.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt
index 7e513609..7271f9c8 100644
--- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt
+++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt
@@ -24,6 +24,9 @@ import timber.log.Timber
private const val INTENT_CODE_MEDIA_BUTTON = 161
+/**
+ * Central place to handle the state of the MediaSession
+ */
class MediaSessionHandler : KoinComponent {
private var mediaSession: MediaSessionCompat? = null
@@ -249,7 +252,7 @@ class MediaSessionHandler : KoinComponent {
mediaSession!!.setQueueTitle(applicationContext.getString(R.string.button_bar_now_playing))
mediaSession!!.setQueue(playlist.mapIndexed { id, song ->
MediaSessionCompat.QueueItem(
- getMediaDescriptionForEntry(song),
+ Util.getMediaDescriptionForEntry(song),
id.toLong())
})
}
@@ -316,66 +319,4 @@ class MediaSessionHandler : KoinComponent {
intent.putExtra(Intent.EXTRA_KEY_EVENT, KeyEvent(KeyEvent.ACTION_DOWN, keycode))
return PendingIntent.getBroadcast(context, requestCode, intent, flags)
}
-
- private fun getMediaDescriptionForEntry(song: MusicDirectory.Entry): MediaDescriptionCompat {
-
- val descriptionBuilder = MediaDescriptionCompat.Builder()
- val artist = StringBuilder(60)
- var bitRate: String? = null
-
- val duration = song.duration
- if (duration != null) {
- artist.append(String.format("%s ", Util.formatTotalDuration(duration.toLong())))
- }
-
- if (song.bitRate != null)
- bitRate = String.format(
- applicationContext.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("%s > %s", suffix, transcodedSuffix)
-
- val artistName = song.artist
-
- if (artistName != null) {
- if (Util.shouldDisplayBitrateWithArtist()) {
- artist.append(artistName).append(" (").append(
- String.format(
- applicationContext.getString(R.string.song_details_all),
- if (bitRate == null) "" else String.format("%s ", bitRate), fileFormat
- )
- ).append(')')
- } else {
- artist.append(artistName)
- }
- }
-
- val trackNumber = song.track ?: 0
-
- val title = StringBuilder(60)
- if (Util.shouldShowTrackNumber() && trackNumber > 0)
- title.append(String.format("%02d - ", trackNumber))
-
- title.append(song.title)
-
- if (song.isVideo && Util.shouldDisplayBitrateWithArtist()) {
- title.append(" (").append(
- String.format(
- applicationContext.getString(R.string.song_details_all),
- if (bitRate == null) "" else String.format("%s ", bitRate), fileFormat
- )
- ).append(')')
- }
-
- descriptionBuilder.setTitle(title)
- descriptionBuilder.setSubtitle(artist)
-
- return descriptionBuilder.build()
- }
}
\ No newline at end of file
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..b6021aee
--- /dev/null
+++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt
@@ -0,0 +1,1320 @@
+/*
+ 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.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.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.inputmethod.InputMethodManager
+import android.widget.Toast
+import androidx.annotation.AnyRes
+import androidx.preference.PreferenceManager
+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
+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.*
+import java.util.concurrent.TimeUnit
+import java.util.regex.Pattern
+import kotlin.math.max
+import kotlin.math.min
+import kotlin.math.roundToInt
+
+/**
+ * @author Sindre Mehus
+ * @version $Id$
+ */
+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
+ private var currentSong: MusicDirectory.Entry? = 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)
+ fun copy(input: InputStream, output: OutputStream): Long {
+ val buffer = ByteArray(1024 * 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("%s.tmp", to.path))
+ val `in` = FileInputStream(from)
+ val out = FileOutputStream(tmp)
+ try {
+ `in`.channel.transferTo(0, from.length(), out.channel)
+ out.close()
+ if (!tmp.renameTo(to)) {
+ throw IOException(String.format("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(`in`)
+ 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 (x: 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 >= 1024 * 1024 * 1024) {
+ return GIGA_BYTE_FORMAT.format(byteCount.toDouble() / (1024 * 1024 * 1024))
+ }
+
+ // More than 1 MB?
+ if (byteCount >= 1024 * 1024) {
+ return MEGA_BYTE_FORMAT.format(byteCount.toDouble() / (1024 * 1024))
+ }
+
+ // More than 1 KB?
+ return if (byteCount >= 1024) {
+ KILO_BYTE_FORMAT.format(byteCount.toDouble() / 1024)
+ } 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
+ fun formatLocalizedBytes(byteCount: Long, context: Context): String {
+
+ // More than 1 GB?
+ if (byteCount >= 1024 * 1024 * 1024) {
+ 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() / (1024 * 1024 * 1024))
+ }
+
+ // More than 1 MB?
+ if (byteCount >= 1024 * 1024) {
+ 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() / (1024 * 1024))
+ }
+
+ // More than 1 KB?
+ if (byteCount >= 1024) {
+ 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() / 1024)
+ }
+ 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.
+ */
+ 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.
+ */
+ 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
+ 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 ((position, _, _, _, _, entry) in bookmarks) {
+ song = entry
+ song.bookmarkPosition = 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 {
+ if (song !== currentSong) {
+ currentSong = song
+ }
+ 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)
+ }
+
+ fun broadcastA2dpPlayStatusChange(
+ context: Context,
+ state: PlayerState?,
+ currentSong: MusicDirectory.Entry?,
+ listSize: Int,
+ id: Int,
+ playerPosition: Int
+ ) {
+ if (!shouldSendBluetoothNotifications) {
+ return
+ }
+ if (currentSong != null) {
+ val avrcpIntent = Intent(CM_AVRCP_PLAYSTATE_CHANGED)
+ if (currentSong == null) {
+ return
+ }
+
+ // FIXME: This is probably a bug.
+ if (currentSong !== currentSong) {
+ Util.currentSong = currentSong
+ }
+ val title = currentSong.title
+ val artist = currentSong.artist
+ val album = currentSong.album
+ val duration = currentSong.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(currentSong)
+ 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
+ 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
+ }
+ }
+
+ 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 * 60 + minutes)
+
+ return when {
+ hours >= 10 -> {
+ String.format(
+ Locale.getDefault(),
+ "%02d:%02d:%02d",
+ hours,
+ minutes,
+ seconds
+ )
+ }
+ hours > 0 -> {
+ String.format(Locale.getDefault(), "%d:%02d:%02d", hours, minutes, seconds)
+ }
+ minutes >= 10 -> {
+ 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 imageLoaderConcurrency(): Int {
+ val preferences = getPreferences()
+ return preferences.getString(
+ Constants.PREFERENCES_KEY_IMAGE_LOADER_CONCURRENCY,
+ "5"
+ )!!.toInt()
+ }
+
+ 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)
+ )
+ }
+
+ fun getMediaDescriptionForEntry(song: MusicDirectory.Entry, mediaId: String? = null): MediaDescriptionCompat {
+
+ val descriptionBuilder = MediaDescriptionCompat.Builder()
+ val artist = StringBuilder(60)
+ var bitRate: String? = null
+
+ val duration = song.duration
+ if (duration != null) {
+ artist.append(String.format("%s ", formatTotalDuration(duration.toLong())))
+ }
+
+ if (song.bitRate != null)
+ 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("%s > %s", suffix, transcodedSuffix)
+
+ val artistName = song.artist
+
+ if (artistName != null) {
+ if (shouldDisplayBitrateWithArtist()) {
+ artist.append(artistName).append(" (").append(
+ String.format(
+ appContext().getString(R.string.song_details_all),
+ if (bitRate == null) "" else String.format("%s ", bitRate), fileFormat
+ )
+ ).append(')')
+ } else {
+ artist.append(artistName)
+ }
+ }
+
+ val trackNumber = song.track ?: 0
+
+ val title = StringBuilder(60)
+ if (shouldShowTrackNumber() && trackNumber > 0)
+ title.append(String.format("%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("%s ", bitRate), fileFormat
+ )
+ ).append(')')
+ }
+
+ descriptionBuilder.setTitle(title)
+ descriptionBuilder.setSubtitle(artist)
+ descriptionBuilder.setMediaId(mediaId)
+
+ return descriptionBuilder.build()
+ }
+}
\ No newline at end of file
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..24b174c7
--- /dev/null
+++ b/ultrasonic/src/main/res/drawable/ic_artist.xml
@@ -0,0 +1,10 @@
+
+
+
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..ef18b12d
--- /dev/null
+++ b/ultrasonic/src/main/res/drawable/ic_library.xml
@@ -0,0 +1,11 @@
+
+
+