package com.simplecity.amp_library.utils; import android.app.Dialog; import android.app.ProgressDialog; import android.content.ContentResolver; import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; import android.content.Intent; import android.database.Cursor; import android.net.Uri; import android.os.Environment; import android.provider.BaseColumns; import android.provider.MediaStore; import android.support.annotation.NonNull; import android.support.annotation.WorkerThread; import android.support.v4.content.LocalBroadcastManager; import android.text.Editable; import android.text.Spannable; import android.text.SpannableStringBuilder; import android.text.TextUtils; import android.text.TextWatcher; import android.text.style.StyleSpan; import android.util.Log; import android.view.LayoutInflater; import android.view.SubMenu; import android.view.View; import android.widget.CheckBox; import android.widget.EditText; import android.widget.TextView; import android.widget.Toast; import com.afollestad.materialdialogs.DialogAction; import com.afollestad.materialdialogs.MaterialDialog; import com.annimon.stream.Collectors; import com.annimon.stream.Stream; import com.crashlytics.android.Crashlytics; import com.simplecity.amp_library.R; import com.simplecity.amp_library.ShuttleApplication; import com.simplecity.amp_library.interfaces.FileType; import com.simplecity.amp_library.model.BaseFileObject; import com.simplecity.amp_library.model.Playlist; import com.simplecity.amp_library.model.Query; import com.simplecity.amp_library.model.Song; import com.simplecity.amp_library.playback.MusicService; import com.simplecity.amp_library.sql.SqlUtils; import com.simplecity.amp_library.sql.providers.PlayCountTable; import com.simplecity.amp_library.sql.sqlbrite.SqlBriteUtils; import com.simplecity.amp_library.ui.views.CustomEditText; import java.io.File; import java.io.FileWriter; import java.io.IOException; import java.util.List; import rx.Observable; import rx.android.schedulers.AndroidSchedulers; import rx.functions.Func1; import rx.schedulers.Schedulers; public class PlaylistUtils { private static final String TAG = "PlaylistUtils"; private PlaylistUtils() { } @WorkerThread public static String makePlaylistName(Context context) { String template = context.getString(R.string.new_playlist_name_template); int num = 1; Query query = new Query.Builder() .uri(MediaStore.Audio.Playlists.EXTERNAL_CONTENT_URI) .projection(new String[]{MediaStore.Audio.Playlists.NAME}) .sort(MediaStore.Audio.Playlists.NAME) .build(); Cursor cursor = SqlUtils.createQuery(context, query); if (cursor != null) { try { String suggestedName = String.format(template, num++); // Need to loop until we've made 1 full pass through without finding a match. // Looping more than once shouldn't happen very often, but will happen // if you have playlists named "New Playlist 1"/10/2/3/4/5/6/7/8/9, where // making only one pass would result in "New Playlist 10" being erroneously // picked for the new name. boolean done = false; while (!done) { done = true; cursor.moveToFirst(); while (!cursor.isAfterLast()) { String playlistName = cursor.getString(0); if (playlistName.compareToIgnoreCase(suggestedName) == 0) { suggestedName = String.format(template, num++); done = false; } cursor.moveToNext(); } } return suggestedName; } finally { cursor.close(); } } return null; } public static Observable<Integer> idForPlaylistObservable(Context context, String name) { Query query = new Query.Builder() .uri(MediaStore.Audio.Playlists.EXTERNAL_CONTENT_URI) .projection(new String[]{MediaStore.Audio.Playlists._ID}) .selection(MediaStore.Audio.Playlists.NAME + "='" + name + "'") .sort(MediaStore.Audio.Playlists.NAME) .build(); return SqlBriteUtils.createSingleQuery(context, cursor -> cursor.getInt(0), -1, query); } public static void createM3uPlaylist(final Context context, final Playlist playlist) { ProgressDialog progressDialog = new ProgressDialog(context); progressDialog.setIndeterminate(true); progressDialog.setTitle(R.string.saving_playlist); progressDialog.show(); playlist.getSongsObservable(context) .flatMap(songs -> { if (!songs.isEmpty()) { File playlistFile = null; if (Environment.getExternalStorageDirectory().canWrite()) { File root = new File(Environment.getExternalStorageDirectory(), "Playlists/Export/"); if (!root.exists()) { root.mkdirs(); } File noMedia = new File(root, ".nomedia"); if (!noMedia.exists()) { try { noMedia.createNewFile(); } catch (IOException e) { e.printStackTrace(); } } String name = playlist.name.replaceAll("[^a-zA-Z0-9.-]", "_"); playlistFile = new File(root, name + ".m3u"); int i = 0; while (playlistFile.exists()) { i++; playlistFile = new File(root, name + i + ".m3u"); } try { FileWriter fileWriter = new FileWriter(playlistFile); StringBuilder body = new StringBuilder(); body.append("#EXTM3U\n"); for (Song song : songs) { body.append("#EXTINF:") .append(song.duration / 1000) .append(",") .append(song.name) .append(" - ") .append(song.artistName) .append("\n") //Todo: Use relative paths instead of absolute .append(song.path) .append("\n"); } fileWriter.append(body); fileWriter.flush(); fileWriter.close(); } catch (IOException e) { Log.e(TAG, "Failed to write file: " + e); } } return Observable.just(playlistFile); } else { return Observable.empty(); } }) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .doOnCompleted(progressDialog::dismiss) .subscribe(file -> { if (file != null) { Toast.makeText(context, String.format(context.getString(R.string.playlist_saved), file.getPath()), Toast.LENGTH_LONG).show(); } else { Toast.makeText(context, R.string.playlist_save_failed, Toast.LENGTH_SHORT).show(); } }); } /** * Clears the 'most played' databse * * @param context Context */ public static void clearMostPlayed(Context context) { context.getContentResolver().delete(PlayCountTable.URI, null, null); } interface OnSavePlaylistListener { void onSave(Playlist playlist); } public static void makePlaylistMenu(Context context, SubMenu sub, int fragmentGroupId) { Query query = new Query.Builder() .uri(MediaStore.Audio.Playlists.EXTERNAL_CONTENT_URI) .projection(new String[]{ BaseColumns._ID, MediaStore.Audio.PlaylistsColumns.NAME }) .build(); SqlBriteUtils.createQuery(context, Playlist::new, query) .observeOn(AndroidSchedulers.mainThread()) .subscribe(playlists -> { sub.clear(); sub.add(fragmentGroupId, MusicUtils.Defs.NEW_PLAYLIST, 0, R.string.new_playlist); for (Playlist playlist : playlists) { final Intent intent = new Intent(); intent.putExtra(ShuttleUtils.ARG_PLAYLIST, playlist); sub.add(fragmentGroupId, MusicUtils.Defs.PLAYLIST_SELECTED, 0, playlist.name).setIntent(intent); } }); } /** * @return true if this item is a favorite */ public static Observable<Boolean> isFavorite(Context context, Song song) { return Observable.fromCallable(Playlist::favoritesPlaylist) .flatMap(playlist -> playlist.getSongsObservable(context)) .flatMap(songs -> Observable.just(songs.contains(song))) .onErrorReturn(throwable -> { Log.e(TAG, "isFavorite() called, playlist null. Returning false"); return false; }); } public static void addFileObjectsToPlaylist(Context context, Playlist playlist, List<BaseFileObject> fileObjects) { ProgressDialog progressDialog = ProgressDialog.show(context, "", context.getString(R.string.gathering_songs), false); long folderCount = Stream.of(fileObjects) .filter(value -> value.fileType == FileType.FOLDER).count(); if (folderCount > 0) { progressDialog.show(); } ShuttleUtils.getSongsForFileObjects(fileObjects) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(songs -> { if (progressDialog != null && progressDialog.isShowing()) { progressDialog.dismiss(); } addToPlaylist(context, playlist, songs); }); } /** * Method addToPlaylist. * * @param playlist Playlist * @param songs List<Song> * @return boolean true if the playlist addition was successful */ public static void addToPlaylist(Context context, Playlist playlist, List<Song> songs) { if (playlist == null || songs == null || songs.isEmpty()) { return; } playlist.getSongsObservable(context) .observeOn(AndroidSchedulers.mainThread()) .subscribe(existingSongs -> { if (!SettingsManager.getInstance().ignoreDuplicates()) { List<Song> duplicates = Stream.of(existingSongs) .filter(songs::contains) .distinct() .collect(Collectors.toList()); if (!duplicates.isEmpty()) { View customView = LayoutInflater.from(context).inflate(R.layout.dialog_playlist_duplicates, null); TextView messageText = (TextView) customView.findViewById(R.id.textView); CheckBox applyToAll = (CheckBox) customView.findViewById(R.id.applyToAll); CheckBox alwaysAdd = (CheckBox) customView.findViewById(R.id.alwaysAdd); if (duplicates.size() <= 1) { applyToAll.setVisibility(View.GONE); applyToAll.setChecked(false); } messageText.setText(getPlaylistRemoveString(context, duplicates.get(0))); applyToAll.setText(getApplyCheckboxString(context, duplicates.size())); DialogUtils.getBuilder(context) .title(R.string.dialog_title_playlist_duplicates) .customView(customView, false) .positiveText(R.string.dialog_button_playlist_duplicate_add) .autoDismiss(false) .onPositive((dialog, which) -> { //If we've only got one item, or we're applying it to all items if (duplicates.size() != 1 && !applyToAll.isChecked()) { //If we're 'adding' this song, we remove it from the 'duplicates' list duplicates.remove(0); messageText.setText(getPlaylistRemoveString(context, duplicates.get(0))); applyToAll.setText(getApplyCheckboxString(context, duplicates.size())); } else { //Add all songs to the playlist insertPlaylistItems(context, playlist, songs, existingSongs.size()); SettingsManager.getInstance().setIgnoreDuplicates(alwaysAdd.isChecked()); dialog.dismiss(); } }) .negativeText(R.string.dialog_button_playlist_duplicate_skip) .onNegative((dialog, which) -> { //If we've only got one item, or we're applying it to all items if (duplicates.size() != 1 && !applyToAll.isChecked()) { //If we're 'skipping' this song, we remove it from the 'duplicates' list, // and from the ids to be added songs.remove(duplicates.remove(0)); messageText.setText(getPlaylistRemoveString(context, duplicates.get(0))); applyToAll.setText(getApplyCheckboxString(context, duplicates.size())); } else { //Remove duplicates from our set of ids Stream.of(duplicates) .filter(songs::contains) .forEach(songs::remove); insertPlaylistItems(context, playlist, songs, existingSongs.size()); SettingsManager.getInstance().setIgnoreDuplicates(alwaysAdd.isChecked()); dialog.dismiss(); } }) .show(); } else { insertPlaylistItems(context, playlist, songs, existingSongs.size()); } } else { insertPlaylistItems(context, playlist, songs, existingSongs.size()); } }); } private static void insertPlaylistItems(@NonNull Context context, @NonNull Playlist playlist, @NonNull List<Song> songs, int songCount) { if (songs.isEmpty()) { return; } ContentValues[] contentValues = new ContentValues[songs.size()]; for (int i = 0, length = songs.size(); i < length; i++) { contentValues[i] = new ContentValues(); contentValues[i].put(MediaStore.Audio.Playlists.Members.PLAY_ORDER, songCount + i); contentValues[i].put(MediaStore.Audio.Playlists.Members.AUDIO_ID, songs.get(i).id); } Uri uri = MediaStore.Audio.Playlists.Members.getContentUri("external", playlist.id); if (uri != null) { ShuttleApplication.getInstance().getContentResolver().bulkInsert(uri, contentValues); PlaylistUtils.showPlaylistToast(context, songs.size()); } } private static String getApplyCheckboxString(Context context, int count) { return String.format(context.getString(R.string.dialog_checkbox_playlist_duplicate_apply_all), count); } private static SpannableStringBuilder getPlaylistRemoveString(Context context, Song song) { SpannableStringBuilder spannableString = new SpannableStringBuilder(String.format(context.getString(R.string.dialog_message_playlist_add_duplicate), song.artistName, song.name)); final StyleSpan boldSpan = new StyleSpan(android.graphics.Typeface.BOLD); spannableString.setSpan(boldSpan, 0, song.artistName.length() + song.name.length() + 3, Spannable.SPAN_INCLUSIVE_INCLUSIVE); return spannableString; } /** * Method clearPlaylist. * * @param context Context * @param playlistId int */ public static void clearPlaylist(Context context, int playlistId) { final Uri uri = MediaStore.Audio.Playlists.Members.getContentUri("external", playlistId); context.getContentResolver().delete(uri, null, null); } public static Playlist createPlaylist(Context context, String name) { Playlist playlist = null; long id = -1; if (!TextUtils.isEmpty(name)) { Query query = new Query.Builder() .uri(MediaStore.Audio.Playlists.EXTERNAL_CONTENT_URI) .projection(new String[]{MediaStore.Audio.PlaylistsColumns.NAME}) .selection(MediaStore.Audio.PlaylistsColumns.NAME + " = '" + name + "'") .build(); final Cursor cursor = SqlUtils.createQuery(context, query); if (cursor != null) { try { int count = cursor.getCount(); if (count <= 0) { final ContentValues values = new ContentValues(1); values.put(MediaStore.Audio.PlaylistsColumns.NAME, name); //Catch NPE occurring on Amazon devices. try { final Uri uri = context.getContentResolver().insert( MediaStore.Audio.Playlists.EXTERNAL_CONTENT_URI, values); if (uri != null) { id = Long.parseLong(uri.getLastPathSegment()); } } catch (NullPointerException e) { Crashlytics.log("Failed to create playlist: " + e.getMessage()); } } } finally { cursor.close(); } } } if (id != -1) { playlist = new Playlist(Playlist.Type.USER_CREATED, id, name, true, false, true, true, true); } else { Crashlytics.log("Failed to create playlist. Id:" + id); } return playlist; } /** * Removes all entries from the 'favorites' playlist */ public static void clearFavorites(Context context) { Playlist favoritesPlaylist = Playlist.favoritesPlaylist(); if (favoritesPlaylist.id >= 0) { final Uri uri = MediaStore.Audio.Playlists.Members.getContentUri("external", favoritesPlaylist.id); context.getContentResolver().delete(uri, null, null); } } public static void toggleFavorite(Context context) { MusicUtils.isFavorite() .subscribeOn(Schedulers.io()) .subscribe(isFavorite -> { if (!isFavorite) { addToFavorites(context); } else { removeFromFavorites(context); } }); } /** * Add a song to the favourites playlist */ public static void addToFavorites(final Context context) { Song song = MusicUtils.getSong(); if (song == null) { return; } Observable.fromCallable(Playlist::favoritesPlaylist) .flatMap(playlist -> playlist.getSongsObservable(context) .flatMap(new Func1<List<Song>, Observable<Playlist>>() { @Override public Observable<Playlist> call(List<Song> songs) { Uri uri = MediaStore.Audio.Playlists.Members.getContentUri("external", playlist.id); ContentValues values = new ContentValues(); values.put(MediaStore.Audio.Playlists.Members.AUDIO_ID, song.id); values.put(MediaStore.Audio.Playlists.Members.PLAY_ORDER, songs.size() + 1); context.getContentResolver().insert(uri, values); return Observable.just(playlist); } })) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(playlist -> { Toast.makeText(context, context.getResources().getString(R.string.song_to_favourites, song.name), Toast.LENGTH_SHORT).show(); LocalBroadcastManager.getInstance(context).sendBroadcast(new Intent(MusicService.InternalIntents.FAVORITE_CHANGED)); }); } public static void removeFromFavorites(Context context) { Song song = MusicUtils.getSong(); if (song == null) { return; } Observable.fromCallable( () -> { Playlist favoritesPlaylist = Playlist.favoritesPlaylist(); if (favoritesPlaylist.id >= 0) { final Uri uri = MediaStore.Audio.Playlists.Members.getContentUri("external", favoritesPlaylist.id); return context.getContentResolver().delete(uri, MediaStore.Audio.Playlists.Members.AUDIO_ID + "=" + song.id, null); } return 0; }) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(numTracksAdded -> { if (numTracksAdded > 0) { Toast.makeText(context, context.getResources().getString(R.string.song_removed_from_favourites, song.name), Toast.LENGTH_SHORT).show(); LocalBroadcastManager.getInstance(context).sendBroadcast(new Intent(MusicService.InternalIntents.FAVORITE_CHANGED)); } }); } public static void showPlaylistToast(Context context, int numTracksAdded) { final String message = context.getResources().getQuantityString(R.plurals.NNNtrackstoplaylist, numTracksAdded, numTracksAdded); Toast.makeText(context, message, Toast.LENGTH_SHORT).show(); } public static void createPlaylistDialog(final Context context, List<Song> songs) { createPlaylistDialog(context, playlistId -> addToPlaylist(context, playlistId, songs)); } public static void createFileObjectPlaylistDialog(final Context context, List<BaseFileObject> fileObjects) { createPlaylistDialog(context, playlistId -> addFileObjectsToPlaylist(context, playlistId, fileObjects)); } private static void createPlaylistDialog(final Context context, final OnSavePlaylistListener listener) { View customView = LayoutInflater.from(context).inflate(R.layout.dialog_playlist, null); final EditText editText = (EditText) customView.findViewById(R.id.editText); Observable.fromCallable(() -> makePlaylistName(context)) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(name -> { editText.setText(name); if (!TextUtils.isEmpty(name)) { editText.setSelection(name.length()); } }); MaterialDialog.Builder builder = DialogUtils.getBuilder(context) .customView(customView, false) .title(R.string.add_to_playlist) .positiveText(R.string.create_playlist_create_text) .onPositive((materialDialog, dialogAction) -> { String name = editText.getText().toString(); if (!name.isEmpty()) { idForPlaylistObservable(context, name) .observeOn(AndroidSchedulers.mainThread()) .subscribe(id -> { Uri uri; if (id >= 0) { uri = ContentUris.withAppendedId(MediaStore.Audio.Playlists.EXTERNAL_CONTENT_URI, id); clearPlaylist(context, id); } else { ContentValues values = new ContentValues(1); values.put(MediaStore.Audio.Playlists.NAME, name); try { uri = context.getContentResolver().insert(MediaStore.Audio.Playlists.EXTERNAL_CONTENT_URI, values); } catch (IllegalArgumentException | NullPointerException e) { Toast.makeText(context, R.string.dialog_create_playlist_error, Toast.LENGTH_LONG).show(); uri = null; } } if (uri != null) { listener.onSave(new Playlist(Playlist.Type.USER_CREATED, Long.valueOf(uri.getLastPathSegment()), name, true, false, true, true, true)); } }); } }) .negativeText(R.string.cancel); final Dialog dialog = builder.build(); dialog.show(); TextWatcher textWatcher = new TextWatcher() { public void beforeTextChanged(CharSequence s, int start, int count, int after) { // don't care about this one } //Fixme: // It's probably best to just query all playlist names first, and then check against //that list, rather than requerying for each char change. public void onTextChanged(CharSequence s, int start, int before, int count) { String newText = editText.getText().toString(); if (newText.trim().length() == 0) { ((MaterialDialog) dialog).getActionButton(DialogAction.POSITIVE).setEnabled(false); } else { ((MaterialDialog) dialog).getActionButton(DialogAction.POSITIVE).setEnabled(true); // check if playlist with current name exists already, and warn the user if so. idForPlaylistObservable(context, newText) .observeOn(AndroidSchedulers.mainThread()) .subscribe(id -> { if (id >= 0) { ((MaterialDialog) dialog).getActionButton(DialogAction.POSITIVE).setText(R.string.create_playlist_overwrite_text); } else { ((MaterialDialog) dialog).getActionButton(DialogAction.POSITIVE).setText(R.string.create_playlist_create_text); } }); } } public void afterTextChanged(Editable s) { // don't care about this one } }; editText.addTextChangedListener(textWatcher); } public static void renamePlaylistDialog(final Context context, final Playlist playlist, final MaterialDialog.SingleButtonCallback listener) { View customView = LayoutInflater.from(context).inflate(R.layout.dialog_playlist, null); final CustomEditText editText = (CustomEditText) customView.findViewById(R.id.editText); editText.setText(playlist.name); MaterialDialog.Builder builder = DialogUtils.getBuilder(context) .title(R.string.create_playlist_create_text_prompt) .customView(customView, false) .positiveText(R.string.save) .onPositive((materialDialog, dialogAction) -> { String name = editText.getText().toString(); if (name.length() > 0) { ContentResolver resolver = context.getContentResolver(); ContentValues values = new ContentValues(1); values.put(MediaStore.Audio.Playlists.NAME, name); resolver.update(MediaStore.Audio.Playlists.EXTERNAL_CONTENT_URI, values, MediaStore.Audio.Playlists._ID + "=?", new String[]{Long.valueOf(playlist.id).toString()} ); playlist.name = name; Toast.makeText(context, R.string.playlist_renamed_message, Toast.LENGTH_SHORT).show(); } if (listener != null) { listener.onClick(materialDialog, dialogAction); } }) .negativeText(R.string.cancel); final MaterialDialog dialog = builder.build(); TextWatcher textWatcher = new TextWatcher() { public void beforeTextChanged(CharSequence s, int start, int count, int after) { } public void onTextChanged(CharSequence s, int start, int before, int count) { // check if playlist with current name exists already, and warn the user if so. setSaveButton(dialog, playlist, editText.getText().toString()); } public void afterTextChanged(Editable s) { } }; editText.addTextChangedListener(textWatcher); dialog.show(); } static void setSaveButton(MaterialDialog dialog, Playlist playlist, String typedName) { if (typedName.trim().length() == 0) { TextView button = dialog.getActionButton(DialogAction.POSITIVE); if (button != null) { button.setEnabled(false); } } else { TextView button = dialog.getActionButton(DialogAction.POSITIVE); if (button != null) { button.setEnabled(true); } if (playlist.id >= 0 && !playlist.name.equals(typedName)) { if (button != null) { button.setText(R.string.create_playlist_overwrite_text); } } else { if (button != null) { button.setText(R.string.create_playlist_create_text); } } } } }