From f50d6f13f445d7068892d08d4eeeea871efffbf9 Mon Sep 17 00:00:00 2001 From: Nite Date: Fri, 16 Jul 2021 17:29:21 +0200 Subject: [PATCH] Started implementing Media Browser Added root menus, playlists and artists --- .../java/org/moire/ultrasonic/util/Util.java | 1353 ----------------- .../ultrasonic/fragment/PlayerFragment.kt | 2 +- .../service/AutoMediaBrowserService.kt | 475 +++++- .../service/MediaPlayerController.kt | 6 +- .../ultrasonic/service/MediaPlayerService.kt | 2 +- .../moire/ultrasonic/subsonic/ShareHandler.kt | 16 +- .../ultrasonic/util/MediaSessionHandler.kt | 67 +- .../kotlin/org/moire/ultrasonic/util/Util.kt | 1320 ++++++++++++++++ .../src/main/res/drawable/ic_artist.xml | 10 + .../src/main/res/drawable/ic_library.xml | 11 + 10 files changed, 1768 insertions(+), 1494 deletions(-) delete mode 100644 ultrasonic/src/main/java/org/moire/ultrasonic/util/Util.java create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt create mode 100644 ultrasonic/src/main/res/drawable/ic_artist.xml create mode 100644 ultrasonic/src/main/res/drawable/ic_library.xml 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: - *

- * - * @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: - * - * 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: - * - * 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 @@ + + +