From e2c39e35ba2cce95b97ec747dd66f793ecd86ea4 Mon Sep 17 00:00:00 2001 From: Martijn Brekhof Date: Tue, 14 Nov 2017 08:30:56 +0100 Subject: [PATCH] Redesigned playing movies on device running Kore * Implemented a new widget "fabspeeddial" * Provides user with two options to play the media item. One option to play the item on Kodi, one to play it on the remote. * Replaced deprecated FAB button from com.melnykov:floatingactionbutton:1.3.0 with the FAB button from the design library * Implemented a busy indicator (pulsate) when fab button is clicked and JSON API method is still pending * Added a setting to allow the user to disable local playback and revert back to the old behaviour * Refactored AbstractFragmentInfo * Replaced RelativeLayout by CoordinatorLayout to support hiding/showing the FAB button when scrolling * Replaced the tree view observer to fade out art view when scrolling with a behavior for the CoordinaterLayout * Removed empty theme file for v19 * Refactored HostConnection to allow new activities to attach its callbacks to any pending ApiMethod. This is required to support device configuration changes. --- app/build.gradle | 2 +- app/src/main/java/org/xbmc/kore/Settings.java | 4 + .../org/xbmc/kore/jsonrpc/HostConnection.java | 102 ++- .../xbmc/kore/ui/AbstractInfoFragment.java | 122 +-- .../xbmc/kore/ui/MovieDetailsFragment.java | 721 ------------------ .../animators/ChangeImageFadeAnimation.java | 102 +++ .../kore/ui/animators/PulsateAnimation.java | 104 +++ .../ui/behaviors/FABSpeedDialBehavior.java | 54 ++ .../FadeOutOnVerticalScrollBehavior.java | 51 ++ .../ui/sections/addon/AddonInfoFragment.java | 11 +- .../ui/sections/audio/AlbumInfoFragment.java | 8 +- .../ui/sections/audio/ArtistInfoFragment.java | 9 +- .../audio/MusicVideoInfoFragment.java | 8 +- .../ui/sections/video/MovieInfoFragment.java | 17 +- .../video/TVShowEpisodeInfoFragment.java | 40 +- .../ui/sections/video/TVShowInfoFragment.java | 4 +- .../fabspeeddial/DialActionButton.java | 206 +++++ .../ui/widgets/fabspeeddial/FABSpeedDial.java | 362 +++++++++ app/src/main/res/color/fabspeeddial.xml | 8 + .../ic_cellphone_android_white_24dp.xml | 8 + .../main/res/drawable/ic_plus_white_24dp.xml | 8 + .../res/drawable/rounded_corners_shape.xml | 6 + .../main/res/layout/dial_action_button.xml | 24 + app/src/main/res/layout/fab_speed_dial.xml | 31 + app/src/main/res/layout/fragment_info.xml | 37 +- app/src/main/res/values-v19/themes.xml | 23 - app/src/main/res/values/attr.xml | 9 +- app/src/main/res/values/colors.xml | 2 + app/src/main/res/values/strings.xml | 3 + app/src/main/res/values/styles.xml | 10 +- app/src/main/res/values/themes.xml | 10 +- app/src/main/res/xml/preferences.xml | 6 + 32 files changed, 1225 insertions(+), 887 deletions(-) delete mode 100644 app/src/main/java/org/xbmc/kore/ui/MovieDetailsFragment.java create mode 100644 app/src/main/java/org/xbmc/kore/ui/animators/ChangeImageFadeAnimation.java create mode 100644 app/src/main/java/org/xbmc/kore/ui/animators/PulsateAnimation.java create mode 100644 app/src/main/java/org/xbmc/kore/ui/behaviors/FABSpeedDialBehavior.java create mode 100644 app/src/main/java/org/xbmc/kore/ui/behaviors/FadeOutOnVerticalScrollBehavior.java create mode 100644 app/src/main/java/org/xbmc/kore/ui/widgets/fabspeeddial/DialActionButton.java create mode 100644 app/src/main/java/org/xbmc/kore/ui/widgets/fabspeeddial/FABSpeedDial.java create mode 100644 app/src/main/res/color/fabspeeddial.xml create mode 100644 app/src/main/res/drawable/ic_cellphone_android_white_24dp.xml create mode 100644 app/src/main/res/drawable/ic_plus_white_24dp.xml create mode 100644 app/src/main/res/drawable/rounded_corners_shape.xml create mode 100644 app/src/main/res/layout/dial_action_button.xml create mode 100644 app/src/main/res/layout/fab_speed_dial.xml delete mode 100644 app/src/main/res/values-v19/themes.xml diff --git a/app/build.gradle b/app/build.gradle index a4ea54b..ed97622 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -116,6 +116,7 @@ dependencies { compile "com.android.support:cardview-v7:${supportLibVersion}" compile "com.android.support:preference-v14:${supportLibVersion}" compile "com.android.support:support-v13:${supportLibVersion}" + compile "com.android.support:design:${supportLibVersion}" compile 'com.fasterxml.jackson.core:jackson-databind:2.5.2' compile 'com.jakewharton:butterknife:6.1.0' @@ -124,7 +125,6 @@ dependencies { compile 'de.greenrobot:eventbus:2.4.0' compile 'org.jmdns:jmdns:3.5.1' compile 'com.astuetz:pagerslidingtabstrip:1.0.1' - compile 'com.melnykov:floatingactionbutton:1.3.0' compile 'at.blogc:expandabletextview:1.0.3' compile 'com.sothree.slidinguppanel:library:3.3.1' diff --git a/app/src/main/java/org/xbmc/kore/Settings.java b/app/src/main/java/org/xbmc/kore/Settings.java index 6f9628b..6fa971a 100644 --- a/app/src/main/java/org/xbmc/kore/Settings.java +++ b/app/src/main/java/org/xbmc/kore/Settings.java @@ -155,6 +155,10 @@ public class Settings { public static final String KEY_PREF_SINGLE_COLUMN = "pref_single_multi_column"; public static final boolean DEFAULT_PREF_SINGLE_COLUMN = false; + // Switch to remote + public static final String KEY_PREF_DISABLE_LOCAL_PLAY = "pref_disable_local_play"; + public static final boolean DEFAULT_PREF_DISABLE_LOCAL_PLAY = false; + /** * Determines the bit flags used by {@link DownloadManager.Request} to correspond to the enabled network connections * from the settings screen. diff --git a/app/src/main/java/org/xbmc/kore/jsonrpc/HostConnection.java b/app/src/main/java/org/xbmc/kore/jsonrpc/HostConnection.java index cb9dccf..f61d6fd 100644 --- a/app/src/main/java/org/xbmc/kore/jsonrpc/HostConnection.java +++ b/app/src/main/java/org/xbmc/kore/jsonrpc/HostConnection.java @@ -15,6 +15,7 @@ */ package org.xbmc.kore.jsonrpc; +import android.annotation.SuppressLint; import android.os.Handler; import android.os.Process; import android.text.TextUtils; @@ -281,11 +282,13 @@ public class HostConnection { } /** - * Calls the a method on the server + * Calls the given method on the server * This call is always asynchronous. The results will be posted, through the * {@link ApiCallback callback} parameter, on the specified {@link android.os.Handler}. - * - * @param method Method object that represents the methood too call + *
+ * If you need to update the callback and handler (e.g. due to a device configuration change) + * use {@link #updateClientCallback(int, ApiCallback, Handler)} + * @param method Method object that represents the methood too call * @param callback {@link ApiCallback} to post the response to * @param handler {@link Handler} to invoke callbacks on. When null, the * callbacks are invoked on the same thread as the request. @@ -297,13 +300,21 @@ public class HostConnection { LogUtils.LOGD(TAG, "Starting method execute. Method: " + method.getMethodName() + " on host: " + hostInfo.getJsonRpcHttpEndpoint()); + if (protocol == PROTOCOL_TCP) { + /** + * Do not call this from the runnable below as it may cause a race condition + * with {@link #updateClientCallback(int, ApiCallback, Handler)} + */ + // Save this method/callback for any later response + addClientCallback(method, callback, handler); + } + // Launch background thread Runnable command = new Runnable() { @Override public void run() { Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND); if (protocol == PROTOCOL_HTTP) { -// executeThroughHttp(method, callback, handler); executeThroughOkHttp(method, callback, handler); } else { executeThroughTcp(method, callback, handler); @@ -342,6 +353,67 @@ public class HostConnection { return ApiFuture.from(this, method); } + /** + * Updates the client callback for the given {@link ApiMethod} if it is still pending. + * This can be used when the activity or fragment has been destroyed and recreated and + * you are still interested in the result of any pending {@link ApiMethod} + * @param methodId for which a new callback needs to be attached + * @param callback new callback that needs to be called for the new activity or fragment + * @param handler used to execute the callback on the UI thread + * @param result type + * @return true if the {@link ApiMethod} was still pending, false otherwise. + */ + @SuppressWarnings("unchecked") + public boolean updateClientCallback(final int methodId, final ApiCallback callback, + final Handler handler) { + + if (getProtocol() == PROTOCOL_HTTP) + return false; + + synchronized (clientCallbacks) { + String id = String.valueOf(methodId); + if (clientCallbacks.containsKey(id)) { + clientCallbacks.put(id, new MethodCallInfo<>((ApiMethod) clientCallbacks.get(id).method, + callback, handler)); + return true; + } + return false; + } + } + + /** + * Stores the method and callback to handle asynchronous responses. + * Note this is only needed for requests over TCP. + * @param method + * @param callback + * @param handler + * @param + */ + private void addClientCallback(final ApiMethod method, final ApiCallback callback, + final Handler handler) { + + if (getProtocol() == PROTOCOL_HTTP) + return; + + String methodId = String.valueOf(method.getId()); + + synchronized (clientCallbacks) { + if (clientCallbacks.containsKey(methodId)) { + if ((handler != null) && (callback != null)) { + handler.post(new Runnable() { + @Override + public void run() { + callback.onError(ApiException.API_METHOD_WITH_SAME_ID_ALREADY_EXECUTING, + "A method with the same Id is already executing"); + } + }); + } + return; + } + clientCallbacks.put(methodId, new MethodCallInfo(method, callback, handler)); + } + } + /** * Sends the JSON RPC request through HTTP (using OkHttp library) */ @@ -355,7 +427,6 @@ public class HostConnection { .url(hostInfo.getJsonRpcHttpEndpoint()) .post(RequestBody.create(MEDIA_TYPE_JSON, jsonRequest)) .build(); - LogUtils.LOGD(TAG, "Sending request via OkHttp: " + jsonRequest); Response response = sendOkHttpRequest(client, request); final T result = method.resultFromJson(parseJsonResponse(handleOkHttpResponse(response))); @@ -517,25 +588,7 @@ public class HostConnection { private void executeThroughTcp(final ApiMethod method, final ApiCallback callback, final Handler handler) { String methodId = String.valueOf(method.getId()); - try { - // Save this method/callback for later response - // Check if a method with this id is already running and raise an error if so - synchronized (clientCallbacks) { - if (clientCallbacks.containsKey(methodId)) { - if (callback != null) { - postOrRunNow(handler, new Runnable() { - @Override - public void run() { - callback.onError(ApiException.API_METHOD_WITH_SAME_ID_ALREADY_EXECUTING, - "A method with the same Id is already executing"); - } - }); - } - return; - } - clientCallbacks.put(methodId, new MethodCallInfo(method, callback, handler)); - } - + try { // TODO: Validate if this shouldn't be enclosed by a synchronized. if (socket == null) { // Open connection to the server and setup reader thread @@ -843,6 +896,7 @@ public class HostConnection { }); } } + clientCallbacks.clear(); } } diff --git a/app/src/main/java/org/xbmc/kore/ui/AbstractInfoFragment.java b/app/src/main/java/org/xbmc/kore/ui/AbstractInfoFragment.java index 28c08c8..8cbe02b 100644 --- a/app/src/main/java/org/xbmc/kore/ui/AbstractInfoFragment.java +++ b/app/src/main/java/org/xbmc/kore/ui/AbstractInfoFragment.java @@ -17,11 +17,14 @@ package org.xbmc.kore.ui; import android.Manifest; +import android.annotation.SuppressLint; import android.annotation.TargetApi; +import android.content.Intent; import android.content.ServiceConnection; import android.content.pm.PackageManager; import android.content.res.Resources; import android.content.res.TypedArray; +import android.net.Uri; import android.os.Bundle; import android.os.Handler; import android.preference.PreferenceManager; @@ -30,6 +33,7 @@ import android.support.annotation.Nullable; import android.support.v4.app.Fragment; import android.support.v4.app.FragmentManager; import android.support.v4.content.ContextCompat; +import android.support.v4.widget.NestedScrollView; import android.support.v4.widget.SwipeRefreshLayout; import android.text.TextUtils; import android.util.DisplayMetrics; @@ -39,17 +43,12 @@ import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; -import android.view.ViewTreeObserver; import android.widget.ImageButton; import android.widget.ImageView; import android.widget.LinearLayout; -import android.widget.ScrollView; import android.widget.TextView; import android.widget.Toast; -import com.melnykov.fab.FloatingActionButton; -import com.melnykov.fab.ObservableScrollView; - import org.xbmc.kore.R; import org.xbmc.kore.Settings; import org.xbmc.kore.host.HostInfo; @@ -60,6 +59,7 @@ import org.xbmc.kore.jsonrpc.type.PlaylistType; import org.xbmc.kore.service.library.LibrarySyncService; import org.xbmc.kore.service.library.SyncUtils; import org.xbmc.kore.ui.generic.RefreshItem; +import org.xbmc.kore.ui.widgets.fabspeeddial.FABSpeedDial; import org.xbmc.kore.utils.LogUtils; import org.xbmc.kore.utils.SharedElementTransition; import org.xbmc.kore.utils.UIUtils; @@ -79,9 +79,11 @@ abstract public class AbstractInfoFragment extends AbstractFragment SharedElementTransition.SharedElement { private static final String TAG = LogUtils.makeLogTag(AbstractInfoFragment.class); + private static final String BUNDLE_KEY_APIMETHOD_PENDING = "pending_apimethod"; + // Detail views @InjectView(R.id.swipe_refresh_layout) SwipeRefreshLayout swipeRefreshLayout; - @InjectView(R.id.media_panel) ScrollView panelScrollView; + @InjectView(R.id.media_panel) NestedScrollView panelScrollView; @InjectView(R.id.art) ImageView artImageView; @InjectView(R.id.poster) ImageView posterImageView; @InjectView(R.id.media_title) TextView titleTextView; @@ -101,7 +103,7 @@ abstract public class AbstractInfoFragment extends AbstractFragment @InjectView(R.id.media_description) ExpandableTextView descriptionExpandableTextView; @InjectView(R.id.media_description_container) LinearLayout descriptionContainer; @InjectView(R.id.show_all) ImageView expansionImage; - @InjectView(R.id.fab) ImageButton fabButton; + @InjectView(R.id.fab) FABSpeedDial fabButton; @InjectView(R.id.exit_transition_view) View exitTransitionView; private HostManager hostManager; @@ -109,6 +111,7 @@ abstract public class AbstractInfoFragment extends AbstractFragment private ServiceConnection serviceConnection; private RefreshItem refreshItem; private boolean expandDescription; + private int methodId; /** * Handler on which to post RPC callbacks @@ -144,17 +147,7 @@ abstract public class AbstractInfoFragment extends AbstractFragment ViewGroup root = (ViewGroup) inflater.inflate(R.layout.fragment_info, container, false); ButterKnife.inject(this, root); - // Setup dim the fanart when scroll changes. Full dim on 4 * iconSize dp Resources resources = getActivity().getResources(); - final int pixelsToTransparent = 4 * resources.getDimensionPixelSize(R.dimen.default_icon_size); - panelScrollView.getViewTreeObserver().addOnScrollChangedListener(new ViewTreeObserver.OnScrollChangedListener() { - @Override - public void onScrollChanged() { - float y = panelScrollView.getScrollY(); - float newAlpha = Math.min(1, Math.max(0, 1 - (y / pixelsToTransparent))); - artImageView.setAlpha(newAlpha); - } - }); DataHolder dataHolder = getDataHolder(); @@ -171,9 +164,6 @@ abstract public class AbstractInfoFragment extends AbstractFragment swipeRefreshLayout.setEnabled(false); } - FloatingActionButton fab = (FloatingActionButton)fabButton; - fab.attachToScrollView((ObservableScrollView) panelScrollView); - if(Utils.isLollipopOrLater()) { posterImageView.setTransitionName(dataHolder.getPosterTransitionName()); } @@ -207,6 +197,14 @@ abstract public class AbstractInfoFragment extends AbstractFragment public void onActivityCreated(@Nullable Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); setHasOptionsMenu(true); + + if (savedInstanceState != null) { + int methodId = savedInstanceState.getInt(BUNDLE_KEY_APIMETHOD_PENDING); + + fabButton.enableBusyAnimation(HostManager.getInstance(getContext()).getConnection() + .updateClientCallback(methodId, createPlayItemOnKodiCallback(), + callbackHandler)); + } } @Override @@ -245,6 +243,13 @@ abstract public class AbstractInfoFragment extends AbstractFragment SyncUtils.disconnectFromLibrarySyncService(getActivity(), serviceConnection); } + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + + outState.putInt(BUNDLE_KEY_APIMETHOD_PENDING, methodId); + } + @Override public boolean onOptionsItemSelected(MenuItem item) { switch (item.getItemId()) { @@ -294,37 +299,24 @@ abstract public class AbstractInfoFragment extends AbstractFragment } } - protected void fabActionPlayItem(PlaylistType.Item item) { + protected void playItemLocally(String url, String type) { + Uri uri = Uri.parse(url); + Intent intent = new Intent(Intent.ACTION_VIEW, uri); + intent.setDataAndType(uri, type); + startActivity(intent); + } + + protected void playItemOnKodi(PlaylistType.Item item) { if (item == null) { Toast.makeText(getActivity(), R.string.no_item_available_to_play, Toast.LENGTH_SHORT).show(); return; } + fabButton.enableBusyAnimation(true); Player.Open action = new Player.Open(item); - action.execute(HostManager.getInstance(getActivity()).getConnection(), new ApiCallback() { - @Override - public void onSuccess(String result) { - if (!isAdded()) return; - // Check whether we should switch to the remote - boolean switchToRemote = PreferenceManager - .getDefaultSharedPreferences(getActivity()) - .getBoolean(Settings.KEY_PREF_SWITCH_TO_REMOTE_AFTER_MEDIA_START, - Settings.DEFAULT_PREF_SWITCH_TO_REMOTE_AFTER_MEDIA_START); - if (switchToRemote) { - int cx = (fabButton.getLeft() + fabButton.getRight()) / 2; - int cy = (fabButton.getTop() + fabButton.getBottom()) / 2; - UIUtils.switchToRemoteWithAnimation(getActivity(), cx, cy, exitTransitionView); - } - } - - @Override - public void onError(int errorCode, String description) { - if (!isAdded()) return; - // Got an error, show toast - Toast.makeText(getActivity(), R.string.unable_to_connect_to_xbmc, Toast.LENGTH_SHORT) - .show(); - } - }, callbackHandler); + methodId = action.getId(); + action.execute(HostManager.getInstance(getActivity()).getConnection(), + createPlayItemOnKodiCallback(), callbackHandler); } @Override @@ -366,6 +358,7 @@ abstract public class AbstractInfoFragment extends AbstractFragment /** * Call this when you are ready to provide the titleTextView, undertitle, details, descriptionExpandableTextView, etc. etc. */ + @SuppressLint("StringFormatInvalid") protected void updateView(DataHolder dataHolder) { titleTextView.setText(dataHolder.getTitle()); underTitleTextView.setText(dataHolder.getUnderTitle()); @@ -497,7 +490,7 @@ abstract public class AbstractInfoFragment extends AbstractFragment /** * Uses colors to show to the user the item has been downloaded - * @param state true if item has been watched/listened too, false otherwise + * @param state true if item has been downloaded, false otherwise */ protected void setDownloadButtonState(boolean state) { UIUtils.highlightImageView(getActivity(), downloadButton, state); @@ -558,6 +551,41 @@ abstract public class AbstractInfoFragment extends AbstractFragment this.expandDescription = expandDescription; } + public FABSpeedDial getFabButton() { + return fabButton; + } + + private ApiCallback createPlayItemOnKodiCallback() { + return new ApiCallback() { + @Override + public void onSuccess(String result) { + if (!isAdded()) return; + fabButton.enableBusyAnimation(false); + + // Check whether we should switch to the remote + boolean switchToRemote = PreferenceManager + .getDefaultSharedPreferences(getActivity()) + .getBoolean(Settings.KEY_PREF_SWITCH_TO_REMOTE_AFTER_MEDIA_START, + Settings.DEFAULT_PREF_SWITCH_TO_REMOTE_AFTER_MEDIA_START); + if (switchToRemote) { + int cx = (fabButton.getLeft() + fabButton.getRight()) / 2; + int cy = (fabButton.getTop() + fabButton.getBottom()) / 2; + UIUtils.switchToRemoteWithAnimation(getActivity(), cx, cy, exitTransitionView); + } + } + + @Override + public void onError(int errorCode, String description) { + if (!isAdded()) return; + fabButton.enableBusyAnimation(false); + + // Got an error, show toast + Toast.makeText(getActivity(), R.string.unable_to_connect_to_xbmc, Toast.LENGTH_SHORT) + .show(); + } + }; + } + abstract protected AbstractAdditionalInfoFragment getAdditionalInfoFragment(); /** @@ -586,5 +614,5 @@ abstract public class AbstractInfoFragment extends AbstractFragment * Called when the fab button is available * @return true to enable the Floating Action Button, false otherwise */ - abstract protected boolean setupFAB(ImageButton FAB); + abstract protected boolean setupFAB(FABSpeedDial FAB); } diff --git a/app/src/main/java/org/xbmc/kore/ui/MovieDetailsFragment.java b/app/src/main/java/org/xbmc/kore/ui/MovieDetailsFragment.java deleted file mode 100644 index 745a55e..0000000 --- a/app/src/main/java/org/xbmc/kore/ui/MovieDetailsFragment.java +++ /dev/null @@ -1,721 +0,0 @@ -/* - * Copyright 2015 Synced Synapse. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.xbmc.kore.ui; - -import android.annotation.TargetApi; -import android.app.AlertDialog; -import android.content.DialogInterface; -import android.content.Intent; -import android.content.res.Resources; -import android.content.res.TypedArray; -import android.database.Cursor; -import android.net.Uri; -import android.os.Bundle; -import android.os.Handler; -import android.preference.PreferenceManager; -import android.provider.BaseColumns; -import android.support.v4.app.LoaderManager; -import android.support.v4.content.CursorLoader; -import android.support.v4.content.Loader; -import android.support.v4.widget.SwipeRefreshLayout; -import android.text.TextUtils; -import android.util.DisplayMetrics; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.view.ViewTreeObserver; -import android.widget.GridLayout; -import android.widget.ImageButton; -import android.widget.ImageView; -import android.widget.ScrollView; -import android.widget.TextView; -import android.widget.Toast; - -import com.melnykov.fab.FloatingActionButton; -import com.melnykov.fab.ObservableScrollView; - -import org.xbmc.kore.R; -import org.xbmc.kore.Settings; -import org.xbmc.kore.host.HostInfo; -import org.xbmc.kore.jsonrpc.ApiCallback; -import org.xbmc.kore.jsonrpc.event.MediaSyncEvent; -import org.xbmc.kore.jsonrpc.method.Player; -import org.xbmc.kore.jsonrpc.method.Playlist; -import org.xbmc.kore.jsonrpc.method.VideoLibrary; -import org.xbmc.kore.jsonrpc.type.PlaylistType; -import org.xbmc.kore.jsonrpc.type.VideoType; -import org.xbmc.kore.provider.MediaContract; -import org.xbmc.kore.service.library.LibrarySyncService; -import org.xbmc.kore.utils.FileDownloadHelper; -import org.xbmc.kore.utils.LogUtils; -import org.xbmc.kore.utils.UIUtils; -import org.xbmc.kore.utils.Utils; - -import java.io.File; -import java.util.ArrayList; - -import butterknife.ButterKnife; -import butterknife.InjectView; -import butterknife.OnClick; - -/** - * Presents movie details - */ -public class MovieDetailsFragment extends AbstractDetailsFragment - implements LoaderManager.LoaderCallbacks { - private static final String TAG = LogUtils.makeLogTag(MovieDetailsFragment.class); - - public static final String BUNDLE_KEY_MOVIETITLE = "movie_title"; - public static final String BUNDLE_KEY_MOVIEPLOT = "movie_plot"; - public static final String BUNDLE_KEY_MOVIEID = "movie_id"; - public static final String POSTER_TRANS_NAME = "POSTER_TRANS_NAME"; - public static final String BUNDLE_KEY_MOVIEGENRES = "movie_genres"; - public static final String BUNDLE_KEY_MOVIEYEAR = "movie_year"; - public static final String BUNDLE_KEY_MOVIERUNTIME = "movie_runtime"; - public static final String BUNDLE_KEY_MOVIERATING = "movie_rating"; - // Loader IDs - private static final int LOADER_MOVIE = 0, - LOADER_CAST = 1; - - /** - * Handler on which to post RPC callbacks - */ - private Handler callbackHandler = new Handler(); - - // Displayed movie id - private int movieId = -1; - private String movieTitle; - - private ArrayList castArrayList; - - // Info for downloading the movie - private FileDownloadHelper.MovieInfo movieDownloadInfo = null; - - // Controls whether a automatic sync refresh has been issued for this show - private static boolean hasIssuedOutdatedRefresh = false; - - @InjectView(R.id.swipe_refresh_layout) SwipeRefreshLayout swipeRefreshLayout; - - @InjectView(R.id.exit_transition_view) View exitTransitionView; - // Buttons - @InjectView(R.id.fab) ImageButton fabButton; - @InjectView(R.id.add_to_playlist) ImageButton addToPlaylistButton; - @InjectView(R.id.go_to_imdb) ImageButton imdbButton; - @InjectView(R.id.download) ImageButton downloadButton; - @InjectView(R.id.seen) ImageButton seenButton; - @InjectView(R.id.local_play) ImageButton localPlayButton; - - // Detail views - @InjectView(R.id.media_panel) ScrollView mediaPanel; - - @InjectView(R.id.art) ImageView mediaArt; - @InjectView(R.id.poster) ImageView mediaPoster; - - @InjectView(R.id.media_title) TextView mediaTitle; - @InjectView(R.id.media_undertitle) TextView mediaUndertitle; - - @InjectView(R.id.rating) TextView mediaRating; - @InjectView(R.id.max_rating) TextView mediaMaxRating; - @InjectView(R.id.year) TextView mediaYear; - @InjectView(R.id.genres) TextView mediaGenres; - @InjectView(R.id.rating_votes) TextView mediaRatingVotes; - - @InjectView(R.id.media_description) TextView mediaDescription; - @InjectView(R.id.directors) TextView mediaDirectors; - @InjectView(R.id.cast_list) GridLayout videoCastList; - - /** - * Create a new instance of this, initialized to show the movie movieId - */ - @TargetApi(21) - public static MovieDetailsFragment newInstance(MovieListFragment.ViewHolder vh) { - MovieDetailsFragment fragment = new MovieDetailsFragment(); - - Bundle args = new Bundle(); - args.putInt(BUNDLE_KEY_MOVIEID, vh.movieId); - args.putString(BUNDLE_KEY_MOVIETITLE, vh.movieTitle); - args.putString(BUNDLE_KEY_MOVIEPLOT, vh.movieTagline); - args.putString(BUNDLE_KEY_MOVIEGENRES, vh.movieGenres); - args.putInt(BUNDLE_KEY_MOVIEYEAR, vh.movieYear); - args.putInt(BUNDLE_KEY_MOVIERUNTIME, vh.movieRuntime); - args.putDouble(BUNDLE_KEY_MOVIERATING, vh.movieRating); - if( Utils.isLollipopOrLater()) { - args.putString(POSTER_TRANS_NAME, vh.artView.getTransitionName()); - } - - fragment.setArguments(args); - return fragment; - } - - @TargetApi(21) - @Override - protected View createView(LayoutInflater inflater, ViewGroup container) { - Bundle bundle = getArguments(); - movieId = bundle.getInt(BUNDLE_KEY_MOVIEID, -1); - - if (movieId == -1) { - // There's nothing to show - return null; - } - - ViewGroup root = (ViewGroup) inflater.inflate(R.layout.fragment_movie_details, container, false); - ButterKnife.inject(this, root); - - //UIUtils.setSwipeRefreshLayoutColorScheme(swipeRefreshLayout); - - // Setup dim the fanart when scroll changes. Full dim on 4 * iconSize dp - Resources resources = getActivity().getResources(); - final int pixelsToTransparent = 4 * resources.getDimensionPixelSize(R.dimen.default_icon_size); - mediaPanel.getViewTreeObserver().addOnScrollChangedListener(new ViewTreeObserver.OnScrollChangedListener() { - @Override - public void onScrollChanged() { - float y = mediaPanel.getScrollY(); - float newAlpha = Math.min(1, Math.max(0, 1 - (y / pixelsToTransparent))); - mediaArt.setAlpha(newAlpha); - } - }); - - FloatingActionButton fab = (FloatingActionButton)fabButton; - fab.attachToScrollView((ObservableScrollView) mediaPanel); - - if(Utils.isLollipopOrLater()) { - mediaPoster.setTransitionName(getArguments().getString(POSTER_TRANS_NAME)); - } - - mediaTitle.setText(bundle.getString(BUNDLE_KEY_MOVIETITLE)); - mediaUndertitle.setText(bundle.getString(BUNDLE_KEY_MOVIEPLOT)); - mediaGenres.setText(bundle.getString(BUNDLE_KEY_MOVIEGENRES)); - setMediaYear(bundle.getInt(BUNDLE_KEY_MOVIERUNTIME), bundle.getInt(BUNDLE_KEY_MOVIEYEAR)); - setMediaRating(bundle.getDouble(BUNDLE_KEY_MOVIERATING)); - - // Pad main content view to overlap with bottom system bar -// UIUtils.setPaddingForSystemBars(getActivity(), mediaPanel, false, false, true); -// mediaPanel.setClipToPadding(false); - - return root; - } - - @Override - protected String getSyncType() { - return LibrarySyncService.SYNC_SINGLE_MOVIE; - } - - @Override - protected String getSyncID() { - return LibrarySyncService.SYNC_MOVIEID; - } - - @Override - protected int getSyncItemID() { - return movieId; - } - - @Override - protected SwipeRefreshLayout getSwipeRefreshLayout() { - return swipeRefreshLayout; - } - - @Override - public void onActivityCreated (Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); - - hasIssuedOutdatedRefresh = false; - - // Start the loaders - getLoaderManager().initLoader(LOADER_MOVIE, null, this); - getLoaderManager().initLoader(LOADER_CAST, null, this); - } - - @Override - public void onResume() { - // Force the exit view to invisible - exitTransitionView.setVisibility(View.INVISIBLE); - //As we make mediaPoster invisible in onStop() we need to make it visible here. - mediaPoster.setVisibility(View.VISIBLE); - super.onResume(); - } - - @Override - public void onStop() { - //For some reason poster is included in the bottom slide animation, by making it invisible it is not noticeable for the user - mediaPoster.setVisibility(View.INVISIBLE); - super.onStop(); - } - - @Override - protected void onSyncProcessEnded(MediaSyncEvent event) { - if (event.status == MediaSyncEvent.STATUS_SUCCESS) { - getLoaderManager().restartLoader(LOADER_MOVIE, null, this); - getLoaderManager().restartLoader(LOADER_CAST, null, this); - } - } - - /** - * Loader callbacks - */ - /** {@inheritDoc} */ - @Override - public Loader onCreateLoader(int i, Bundle bundle) { - Uri uri; - switch (i) { - case LOADER_MOVIE: - uri = MediaContract.Movies.buildMovieUri(getHostInfo().getId(), movieId); - return new CursorLoader(getActivity(), uri, - MovieDetailsQuery.PROJECTION, null, null, null); - case LOADER_CAST: - uri = MediaContract.MovieCast.buildMovieCastListUri(getHostInfo().getId(), movieId); - return new CursorLoader(getActivity(), uri, - MovieCastListQuery.PROJECTION, null, null, MovieCastListQuery.SORT); - default: - return null; - } - } - - /** {@inheritDoc} */ - @Override - public void onLoadFinished(Loader cursorLoader, Cursor cursor) { - if (cursor != null && cursor.getCount() > 0) { - switch (cursorLoader.getId()) { - case LOADER_MOVIE: - displayMovieDetails(cursor); - checkOutdatedMovieDetails(cursor); - break; - case LOADER_CAST: - displayCastList(cursor); - break; - } - } - } - - /** {@inheritDoc} */ - @Override - public void onLoaderReset(Loader cursorLoader) { - // Release loader's data - } - - /** - * Callbacks for button bar - */ - @OnClick(R.id.fab) - public void onFabClicked(View v) { - PlaylistType.Item item = new PlaylistType.Item(); - item.movieid = movieId; - Player.Open action = new Player.Open(item); - action.execute(getHostManager().getConnection(), new ApiCallback() { - @Override - public void onSuccess(String result) { - if (!isAdded()) return; - // Check whether we should switch to the remote - boolean switchToRemote = PreferenceManager - .getDefaultSharedPreferences(getActivity()) - .getBoolean(Settings.KEY_PREF_SWITCH_TO_REMOTE_AFTER_MEDIA_START, - Settings.DEFAULT_PREF_SWITCH_TO_REMOTE_AFTER_MEDIA_START); - if (switchToRemote) { - int cx = (fabButton.getLeft() + fabButton.getRight()) / 2; - int cy = (fabButton.getTop() + fabButton.getBottom()) / 2; - UIUtils.switchToRemoteWithAnimation(getActivity(), cx, cy, exitTransitionView); - } - } - - @Override - public void onError(int errorCode, String description) { - if (!isAdded()) return; - // Got an error, show toast - Toast.makeText(getActivity(), R.string.unable_to_connect_to_xbmc, Toast.LENGTH_SHORT) - .show(); - } - }, callbackHandler); - } - - @OnClick(R.id.add_to_playlist) - public void onAddToPlaylistClicked(View v) { - Playlist.GetPlaylists getPlaylists = new Playlist.GetPlaylists(); - - getPlaylists.execute(getHostManager().getConnection(), new ApiCallback>() { - @Override - public void onSuccess(ArrayList result) { - if (!isAdded()) return; - // Ok, loop through the playlists, looking for the video one - int videoPlaylistId = -1; - for (PlaylistType.GetPlaylistsReturnType playlist : result) { - if (playlist.type.equals(PlaylistType.GetPlaylistsReturnType.VIDEO)) { - videoPlaylistId = playlist.playlistid; - break; - } - } - // If found, add to playlist - if (videoPlaylistId != -1) { - PlaylistType.Item item = new PlaylistType.Item(); - item.movieid = movieId; - Playlist.Add action = new Playlist.Add(videoPlaylistId, item); - action.execute(getHostManager().getConnection(), new ApiCallback() { - @Override - public void onSuccess(String result) { - if (!isAdded()) return; - // Got an error, show toast - Toast.makeText(getActivity(), R.string.item_added_to_playlist, Toast.LENGTH_SHORT) - .show(); - } - - @Override - public void onError(int errorCode, String description) { - if (!isAdded()) return; - // Got an error, show toast - Toast.makeText(getActivity(), R.string.unable_to_connect_to_xbmc, Toast.LENGTH_SHORT) - .show(); - } - }, callbackHandler); - } else { - Toast.makeText(getActivity(), R.string.no_suitable_playlist, Toast.LENGTH_SHORT) - .show(); - } - } - - @Override - public void onError(int errorCode, String description) { - if (!isAdded()) return; - // Got an error, show toast - Toast.makeText(getActivity(), R.string.unable_to_connect_to_xbmc, Toast.LENGTH_SHORT) - .show(); - } - }, callbackHandler); - } - - @OnClick(R.id.go_to_imdb) - public void onImdbClicked(View v) { - String imdbNumber = (String)v.getTag(); - - if (imdbNumber != null) { - Utils.openImdbForMovie(getActivity(), imdbNumber); - } - } - - @OnClick(R.id.seen) - public void onSeenClicked(View v) { - // Set the playcount - Integer playcount = (Integer)v.getTag(); - int newPlaycount = (playcount > 0) ? 0 : 1; - - VideoLibrary.SetMovieDetails action = - new VideoLibrary.SetMovieDetails(movieId, newPlaycount, null); - action.execute(getHostManager().getConnection(), new ApiCallback() { - @Override - public void onSuccess(String result) { - if (!isAdded()) return; - // Force a refresh, but don't show a message - startSync(true); - } - - @Override - public void onError(int errorCode, String description) { } - }, callbackHandler); - - // Change the button, to provide imeddiate feedback, even if it isn't yet stored in the db - // (will be properly updated and refreshed after the refresh callback ends) - setupSeenButton(newPlaycount); - } - - @OnClick(R.id.local_play) - public void onLocalPlayClicked(View v) { - if (movieDownloadInfo == null) { - // Nothing to play on local - Toast.makeText(getActivity(), R.string.no_files_to_play, Toast.LENGTH_SHORT).show(); - return; - } - - String videoUrl = movieDownloadInfo.getMediaUrl(getHostInfo()); - Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(videoUrl)); - intent.setDataAndType(Uri.parse(videoUrl), "video/*"); - startActivity(intent); - } - - @Override - protected void onDownload() { - if (movieDownloadInfo == null) { - // Nothing to download - Toast.makeText(getActivity(), R.string.no_files_to_download, Toast.LENGTH_SHORT).show(); - return; - } - - DialogInterface.OnClickListener noopClickListener = - new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { } - }; - - // Check if the directory exists and whether to overwrite it - File file = new File(movieDownloadInfo.getAbsoluteFilePath()); - if (file.exists()) { - AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); - builder.setTitle(R.string.download) - .setMessage(R.string.download_file_exists) - .setPositiveButton(R.string.overwrite, - new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - FileDownloadHelper.downloadFiles(getActivity(), getHostInfo(), - movieDownloadInfo, FileDownloadHelper.OVERWRITE_FILES, - callbackHandler); - } - }) - .setNeutralButton(R.string.download_with_new_name, - new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - FileDownloadHelper.downloadFiles(getActivity(), getHostInfo(), - movieDownloadInfo, FileDownloadHelper.DOWNLOAD_WITH_NEW_NAME, - callbackHandler); - } - }) - .setNegativeButton(android.R.string.cancel, noopClickListener) - .show(); - } else { - // Confirm that the user really wants to download the file - AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); - builder.setTitle(R.string.download) - .setMessage(R.string.confirm_movie_download) - .setPositiveButton(android.R.string.ok, - new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - FileDownloadHelper.downloadFiles(getActivity(), getHostInfo(), - movieDownloadInfo, FileDownloadHelper.OVERWRITE_FILES, - callbackHandler); - } - }) - .setNegativeButton(android.R.string.cancel, noopClickListener) - .show(); - } - } - - /** - * Display the movie details - * - * @param cursor Cursor with the data - */ - private void displayMovieDetails(Cursor cursor) { - LogUtils.LOGD(TAG, "Refreshing movie details"); - cursor.moveToFirst(); - movieTitle = cursor.getString(MovieDetailsQuery.TITLE); - mediaTitle.setText(movieTitle); - mediaUndertitle.setText(cursor.getString(MovieDetailsQuery.TAGLINE)); - - setMediaYear(cursor.getInt(MovieDetailsQuery.RUNTIME) / 60, cursor.getInt(MovieDetailsQuery.YEAR)); - - mediaGenres.setText(cursor.getString(MovieDetailsQuery.GENRES)); - - double rating = cursor.getDouble(MovieDetailsQuery.RATING); - if (rating > 0) { - mediaRating.setVisibility(View.VISIBLE); - mediaMaxRating.setVisibility(View.VISIBLE); - mediaRatingVotes.setVisibility(View.VISIBLE); - setMediaRating(rating); - String votes = cursor.getString(MovieDetailsQuery.VOTES); - mediaRatingVotes.setText((TextUtils.isEmpty(votes)) ? - "" : String.format(getString(R.string.votes), votes)); - } else { - mediaRating.setVisibility(View.INVISIBLE); - mediaMaxRating.setVisibility(View.INVISIBLE); - mediaRatingVotes.setVisibility(View.INVISIBLE); - } - - mediaDescription.setText(cursor.getString(MovieDetailsQuery.PLOT)); - mediaDirectors.setText(cursor.getString(MovieDetailsQuery.DIRECTOR)); - - // IMDB button - imdbButton.setTag(cursor.getString(MovieDetailsQuery.IMDBNUMBER)); - - setupSeenButton(cursor.getInt(MovieDetailsQuery.PLAYCOUNT)); - - // Images - Resources resources = getActivity().getResources(); - DisplayMetrics displayMetrics = new DisplayMetrics(); - getActivity().getWindowManager().getDefaultDisplay().getMetrics(displayMetrics); - - int posterWidth = resources.getDimensionPixelOffset(R.dimen.now_playing_poster_width); - int posterHeight = resources.getDimensionPixelOffset(R.dimen.now_playing_poster_height); - UIUtils.loadImageWithCharacterAvatar(getActivity(), getHostManager(), - cursor.getString(MovieDetailsQuery.THUMBNAIL), movieTitle, - mediaPoster, posterWidth, posterHeight); - int artHeight = resources.getDimensionPixelOffset(R.dimen.now_playing_art_height); - UIUtils.loadImageIntoImageview(getHostManager(), - cursor.getString(MovieDetailsQuery.FANART), - mediaArt, displayMetrics.widthPixels, artHeight); - - // Setup movie download info - movieDownloadInfo = new FileDownloadHelper.MovieInfo( - movieTitle, cursor.getString(MovieDetailsQuery.FILE)); - - // Check if downloaded file exists - if (movieDownloadInfo.downloadFileExists()) { - Resources.Theme theme = getActivity().getTheme(); - TypedArray styledAttributes = theme.obtainStyledAttributes(new int[]{ - R.attr.colorAccent}); - downloadButton.setColorFilter( - styledAttributes.getColor(0, - getActivity().getResources().getColor(R.color.accent_default))); - styledAttributes.recycle(); - } else { - downloadButton.clearColorFilter(); - } - } - - private void setMediaRating(double rating) { - mediaRating.setText(String.format("%01.01f", rating)); - mediaMaxRating.setText(getString(R.string.max_rating_video)); - } - - private void setMediaYear(int runtime, int year) { - String durationYear = runtime > 0 ? - String.format(getString(R.string.minutes_abbrev), String.valueOf(runtime)) + - " | " + year : - String.valueOf(year); - mediaYear.setText(durationYear); - } - - private void setupSeenButton(int playcount) { - // Seen button - if (playcount > 0) { - Resources.Theme theme = getActivity().getTheme(); - TypedArray styledAttributes = theme.obtainStyledAttributes(new int[] { - R.attr.colorAccent}); - seenButton.setColorFilter(styledAttributes.getColor(0, - getActivity().getResources().getColor(R.color.accent_default))); - styledAttributes.recycle(); - } else { - seenButton.clearColorFilter(); - } - // Save the playcount - seenButton.setTag(playcount); - } - - /** - * Display the cast details - * - * @param cursor Cursor with the data - */ - private void displayCastList(Cursor cursor) { - // Transform the cursor into a List - - if (cursor.moveToFirst()) { - castArrayList = new ArrayList(cursor.getCount()); - do { - castArrayList.add(new VideoType.Cast(cursor.getString(MovieCastListQuery.NAME), - cursor.getInt(MovieCastListQuery.ORDER), - cursor.getString(MovieCastListQuery.ROLE), - cursor.getString(MovieCastListQuery.THUMBNAIL))); - } while (cursor.moveToNext()); - - UIUtils.setupCastInfo(getActivity(), castArrayList, videoCastList, - AllCastActivity.buildLaunchIntent(getActivity(), movieTitle, castArrayList)); - } - } - - /** - * Checks wether we should refresh the movie details with the info on XBMC - * The details will be updated if the last update is older than what is configured in the - * settings - * - * @param cursor Cursor with the data - */ - private void checkOutdatedMovieDetails(Cursor cursor) { - if (hasIssuedOutdatedRefresh) - return; - - cursor.moveToFirst(); - long lastUpdated = cursor.getLong(MovieDetailsQuery.UPDATED); - - if (System.currentTimeMillis() > lastUpdated + Settings.DB_UPDATE_INTERVAL) { - // Trigger a silent refresh - hasIssuedOutdatedRefresh = true; - startSync(true); - } - } - - /** - * Returns the shared element if visible - * @return View if visible, null otherwise - */ - public View getSharedElement() { - if (UIUtils.isViewInBounds(mediaPanel, mediaPoster)) { - return mediaPoster; - } - - return null; - } - - /** - * Movie details query parameters. - */ - private interface MovieDetailsQuery { - String[] PROJECTION = { - BaseColumns._ID, - MediaContract.Movies.TITLE, - MediaContract.Movies.TAGLINE, - MediaContract.Movies.THUMBNAIL, - MediaContract.Movies.FANART, - MediaContract.Movies.YEAR, - MediaContract.Movies.GENRES, - MediaContract.Movies.RUNTIME, - MediaContract.Movies.RATING, - MediaContract.Movies.VOTES, - MediaContract.Movies.PLOT, - MediaContract.Movies.PLAYCOUNT, - MediaContract.Movies.DIRECTOR, - MediaContract.Movies.IMDBNUMBER, - MediaContract.Movies.FILE, - MediaContract.SyncColumns.UPDATED, - }; - - final int ID = 0; - final int TITLE = 1; - final int TAGLINE = 2; - final int THUMBNAIL = 3; - final int FANART = 4; - final int YEAR = 5; - final int GENRES = 6; - final int RUNTIME = 7; - final int RATING = 8; - final int VOTES = 9; - final int PLOT = 10; - final int PLAYCOUNT = 11; - final int DIRECTOR = 12; - final int IMDBNUMBER = 13; - final int FILE = 14; - final int UPDATED = 15; - } - - /** - * Movie cast list query parameters. - */ - public interface MovieCastListQuery { - String[] PROJECTION = { - BaseColumns._ID, - MediaContract.MovieCast.NAME, - MediaContract.MovieCast.ORDER, - MediaContract.MovieCast.ROLE, - MediaContract.MovieCast.THUMBNAIL, - }; - - String SORT = MediaContract.MovieCast.ORDER + " ASC"; - - final int ID = 0; - final int NAME = 1; - final int ORDER = 2; - final int ROLE = 3; - final int THUMBNAIL = 4; - } -} diff --git a/app/src/main/java/org/xbmc/kore/ui/animators/ChangeImageFadeAnimation.java b/app/src/main/java/org/xbmc/kore/ui/animators/ChangeImageFadeAnimation.java new file mode 100644 index 0000000..ea98c54 --- /dev/null +++ b/app/src/main/java/org/xbmc/kore/ui/animators/ChangeImageFadeAnimation.java @@ -0,0 +1,102 @@ +/* + * Copyright 2017 Martijn Brekhof. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.xbmc.kore.ui.animators; + +import android.animation.Animator; +import android.animation.ValueAnimator; +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.support.annotation.NonNull; +import android.support.design.widget.FloatingActionButton; + +import org.xbmc.kore.utils.LogUtils; + +public class ChangeImageFadeAnimation { + + private Drawable fadeOutImage; + private Drawable fadeInImage; + private Drawable animatedImage; + private FloatingActionButton imageHolder; + + private ValueAnimator fadeOutAnimator; + + private ChangeImageFadeAnimation() { + + } + + public ChangeImageFadeAnimation(@NonNull FloatingActionButton imageHolder, + @NonNull Drawable fadeOutImage, @NonNull Drawable fadeInImage) { + this.fadeOutImage = fadeOutImage.getConstantState().newDrawable(); + this.fadeOutImage.mutate(); + this.fadeInImage = fadeInImage.getConstantState().newDrawable(); + this.fadeInImage.mutate(); + + this.imageHolder = imageHolder; + setupAnimation(); + } + + public void cancel() { + fadeOutAnimator.cancel(); + } + + public void start() { + fadeOutAnimator.start(); + } + + private void setupAnimation() { + fadeOutAnimator = new ValueAnimator(); + fadeOutAnimator.setIntValues(255, 0); + fadeOutAnimator.setDuration(500); + final ValueAnimator fadeInAnimator = new ValueAnimator(); + fadeInAnimator.setIntValues(0, 255); + fadeInAnimator.setDuration(500); + animatedImage = fadeOutImage; + + ValueAnimator.AnimatorUpdateListener updateListener = new ValueAnimator.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator animation) { + animatedImage.setAlpha((int) animation.getAnimatedValue()); + } + }; + fadeInAnimator.addUpdateListener(updateListener); + fadeOutAnimator.addUpdateListener(updateListener); + + fadeOutAnimator.addListener(new Animator.AnimatorListener() { + @Override + public void onAnimationStart(Animator animator) { + + } + + @Override + public void onAnimationEnd(Animator animator) { + animatedImage = fadeInImage; + animatedImage.setAlpha(0); + imageHolder.setImageDrawable(animatedImage); + fadeInAnimator.start(); + } + + @Override + public void onAnimationCancel(Animator animator) { + + } + + @Override + public void onAnimationRepeat(Animator animator) { + + } + }); + } +} diff --git a/app/src/main/java/org/xbmc/kore/ui/animators/PulsateAnimation.java b/app/src/main/java/org/xbmc/kore/ui/animators/PulsateAnimation.java new file mode 100644 index 0000000..afcf722 --- /dev/null +++ b/app/src/main/java/org/xbmc/kore/ui/animators/PulsateAnimation.java @@ -0,0 +1,104 @@ +/* + * Copyright 2017 Martijn Brekhof. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.xbmc.kore.ui.animators; + +import android.animation.Animator; +import android.animation.AnimatorSet; +import android.animation.ArgbEvaluator; +import android.animation.ValueAnimator; +import android.graphics.PorterDuff; +import android.view.View; + +public class PulsateAnimation { + + private View view; + private AnimatorSet animatorSet; + private boolean stopAnimation; + private int startColor; + private int endColor; + + private PulsateAnimation() { + + } + + public PulsateAnimation(View v, int startColor, int endColor) { + view = v; + this.startColor = startColor; + this.endColor = endColor; + + setupAnimation(); + } + + public void start() { + stopAnimation = false; + animatorSet.start(); + } + + public void stop() { + stopAnimation = true; + } + + public boolean isRunning() { + return animatorSet.isRunning(); + } + + private void setupAnimation() { + animatorSet = new AnimatorSet(); + + //Creates an animation that first changes color from startColor to endColor and + //afterwards changes color from endColor to startColor + animatorSet.playSequentially(createValueAnimator(startColor, endColor), + createValueAnimator(endColor, startColor)); + + animatorSet.addListener(new Animator.AnimatorListener() { + @Override + public void onAnimationStart(Animator animation) { + + } + + @Override + public void onAnimationEnd(Animator animation) { + if (!stopAnimation) + animatorSet.start(); + } + + @Override + public void onAnimationCancel(Animator animation) { + + } + + @Override + public void onAnimationRepeat(Animator animation) { + + } + }); + } + + private ValueAnimator createValueAnimator(int startColor, int endColor) { + ValueAnimator valueAnimator = new ValueAnimator(); + valueAnimator.setDuration(1000); + valueAnimator.setIntValues(startColor, endColor); + valueAnimator.setEvaluator(new ArgbEvaluator()); + valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator animator) { + int color = (int) animator.getAnimatedValue(); + view.getBackground().setColorFilter(color, PorterDuff.Mode.SRC_IN); + } + }); + return valueAnimator; + } +} diff --git a/app/src/main/java/org/xbmc/kore/ui/behaviors/FABSpeedDialBehavior.java b/app/src/main/java/org/xbmc/kore/ui/behaviors/FABSpeedDialBehavior.java new file mode 100644 index 0000000..7fbd6b6 --- /dev/null +++ b/app/src/main/java/org/xbmc/kore/ui/behaviors/FABSpeedDialBehavior.java @@ -0,0 +1,54 @@ +/* + * Copyright 2017 Martijn Brekhof. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.xbmc.kore.ui.behaviors; + +import android.content.Context; +import android.support.annotation.NonNull; +import android.support.design.widget.CoordinatorLayout; +import android.support.v4.view.ViewCompat; +import android.util.AttributeSet; +import android.view.View; + +public class FABSpeedDialBehavior extends CoordinatorLayout.Behavior { + + private boolean hide; + + public FABSpeedDialBehavior(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + public boolean onStartNestedScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull View child, @NonNull View directTargetChild, @NonNull View target, int axes, int type) { + //Make sure we respond to vertical scroll events + return axes == ViewCompat.SCROLL_AXIS_VERTICAL || + super.onStartNestedScroll(coordinatorLayout, child, directTargetChild, target, + axes, type); + } + + @Override + public void onNestedScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull final View child, @NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type) { + super.onNestedScroll(coordinatorLayout, child, target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, type); + + if (dyConsumed > 0 && !hide) { + hide = true; + ViewCompat.animate(child).translationY(child.getHeight()); + } else if (dyConsumed < 0 && hide) { + hide = false; + ViewCompat.animate(child).translationY(0); + } + } +} diff --git a/app/src/main/java/org/xbmc/kore/ui/behaviors/FadeOutOnVerticalScrollBehavior.java b/app/src/main/java/org/xbmc/kore/ui/behaviors/FadeOutOnVerticalScrollBehavior.java new file mode 100644 index 0000000..194a872 --- /dev/null +++ b/app/src/main/java/org/xbmc/kore/ui/behaviors/FadeOutOnVerticalScrollBehavior.java @@ -0,0 +1,51 @@ +/* + * Copyright 2017 Martijn Brekhof. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.xbmc.kore.ui.behaviors; + +import android.content.Context; +import android.support.annotation.NonNull; +import android.support.design.widget.CoordinatorLayout; +import android.support.v4.view.ViewCompat; +import android.util.AttributeSet; +import android.view.View; + +public class FadeOutOnVerticalScrollBehavior extends CoordinatorLayout.Behavior { + + private int maxScroll = 0; + private int currentScroll; + + public FadeOutOnVerticalScrollBehavior(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + public boolean onStartNestedScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull View child, @NonNull View directTargetChild, @NonNull View target, int axes, int type) { + if ( axes == ViewCompat.SCROLL_AXIS_VERTICAL ) { + if (maxScroll == 0) + maxScroll = child.getHeight(); + + return true; + } + return false; + } + + @Override + public void onNestedScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull View child, @NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type) { + currentScroll += dyConsumed; + child.setAlpha((float) ((maxScroll - currentScroll) / (double) maxScroll)); + } +} diff --git a/app/src/main/java/org/xbmc/kore/ui/sections/addon/AddonInfoFragment.java b/app/src/main/java/org/xbmc/kore/ui/sections/addon/AddonInfoFragment.java index 07f0b3f..f80192b 100644 --- a/app/src/main/java/org/xbmc/kore/ui/sections/addon/AddonInfoFragment.java +++ b/app/src/main/java/org/xbmc/kore/ui/sections/addon/AddonInfoFragment.java @@ -20,7 +20,6 @@ import android.content.SharedPreferences; import android.os.Bundle; import android.os.Handler; import android.view.View; -import android.widget.ImageButton; import android.widget.Toast; import org.xbmc.kore.R; @@ -30,6 +29,7 @@ import org.xbmc.kore.jsonrpc.type.AddonType; import org.xbmc.kore.ui.AbstractAdditionalInfoFragment; import org.xbmc.kore.ui.AbstractInfoFragment; import org.xbmc.kore.ui.generic.RefreshItem; +import org.xbmc.kore.ui.widgets.fabspeeddial.FABSpeedDial; import org.xbmc.kore.utils.LogUtils; import java.util.Collections; @@ -86,20 +86,23 @@ public class AddonInfoFragment extends AbstractInfoFragment { } @Override - protected boolean setupFAB(ImageButton FAB) { - FAB.setOnClickListener(new View.OnClickListener() { + protected boolean setupFAB(final FABSpeedDial FAB) { + FAB.setOnFabClickListener(new View.OnClickListener() { @Override public void onClick(View v) { + FAB.enableBusyAnimation(true); Addons.ExecuteAddon action = new Addons.ExecuteAddon(addonId); action.execute(getHostManager().getConnection(), new ApiCallback() { @Override public void onSuccess(String result) { - // Do nothing + if (!isAdded()) return; + FAB.enableBusyAnimation(false); } @Override public void onError(int errorCode, String description) { if (!isAdded()) return; + FAB.enableBusyAnimation(false); // Got an error, show toast Toast.makeText(getActivity(), R.string.unable_to_connect_to_xbmc, Toast.LENGTH_SHORT) .show(); diff --git a/app/src/main/java/org/xbmc/kore/ui/sections/audio/AlbumInfoFragment.java b/app/src/main/java/org/xbmc/kore/ui/sections/audio/AlbumInfoFragment.java index 2b1851c..a44f588 100644 --- a/app/src/main/java/org/xbmc/kore/ui/sections/audio/AlbumInfoFragment.java +++ b/app/src/main/java/org/xbmc/kore/ui/sections/audio/AlbumInfoFragment.java @@ -26,7 +26,6 @@ import android.support.v4.content.CursorLoader; import android.support.v4.content.Loader; import android.text.TextUtils; import android.view.View; -import android.widget.ImageButton; import android.widget.Toast; import org.xbmc.kore.R; @@ -40,6 +39,7 @@ import org.xbmc.kore.service.library.LibrarySyncService; import org.xbmc.kore.ui.AbstractAdditionalInfoFragment; import org.xbmc.kore.ui.AbstractInfoFragment; import org.xbmc.kore.ui.generic.RefreshItem; +import org.xbmc.kore.ui.widgets.fabspeeddial.FABSpeedDial; import org.xbmc.kore.utils.FileDownloadHelper; import org.xbmc.kore.utils.LogUtils; import org.xbmc.kore.utils.UIUtils; @@ -172,13 +172,13 @@ public class AlbumInfoFragment extends AbstractInfoFragment } @Override - protected boolean setupFAB(ImageButton FAB) { - FAB.setOnClickListener(new View.OnClickListener() { + protected boolean setupFAB(FABSpeedDial FAB) { + FAB.setOnFabClickListener(new View.OnClickListener() { @Override public void onClick(View v) { PlaylistType.Item item = new PlaylistType.Item(); item.albumid = getDataHolder().getId(); - fabActionPlayItem(item); + playItemOnKodi(item); } }); return true; diff --git a/app/src/main/java/org/xbmc/kore/ui/sections/audio/ArtistInfoFragment.java b/app/src/main/java/org/xbmc/kore/ui/sections/audio/ArtistInfoFragment.java index fd77db4..cecdded 100644 --- a/app/src/main/java/org/xbmc/kore/ui/sections/audio/ArtistInfoFragment.java +++ b/app/src/main/java/org/xbmc/kore/ui/sections/audio/ArtistInfoFragment.java @@ -26,7 +26,6 @@ import android.support.v4.app.LoaderManager; import android.support.v4.content.CursorLoader; import android.support.v4.content.Loader; import android.view.View; -import android.widget.ImageButton; import org.xbmc.kore.jsonrpc.event.MediaSyncEvent; import org.xbmc.kore.jsonrpc.type.PlaylistType; @@ -34,10 +33,10 @@ import org.xbmc.kore.provider.MediaContract; import org.xbmc.kore.provider.MediaDatabase; import org.xbmc.kore.provider.MediaProvider; import org.xbmc.kore.service.library.LibrarySyncService; -import org.xbmc.kore.service.library.SyncMusic; import org.xbmc.kore.ui.AbstractAdditionalInfoFragment; import org.xbmc.kore.ui.AbstractInfoFragment; import org.xbmc.kore.ui.generic.RefreshItem; +import org.xbmc.kore.ui.widgets.fabspeeddial.FABSpeedDial; import org.xbmc.kore.utils.FileDownloadHelper; import org.xbmc.kore.utils.LogUtils; import org.xbmc.kore.utils.MediaPlayerUtils; @@ -98,13 +97,13 @@ public class ArtistInfoFragment extends AbstractInfoFragment } @Override - protected boolean setupFAB(ImageButton FAB) { - FAB.setOnClickListener(new View.OnClickListener() { + protected boolean setupFAB(FABSpeedDial FAB) { + FAB.setOnFabClickListener(new View.OnClickListener() { @Override public void onClick(View v) { PlaylistType.Item item = new PlaylistType.Item(); item.artistid = getDataHolder().getId(); - fabActionPlayItem(item); + playItemOnKodi(item); } }); return true; diff --git a/app/src/main/java/org/xbmc/kore/ui/sections/audio/MusicVideoInfoFragment.java b/app/src/main/java/org/xbmc/kore/ui/sections/audio/MusicVideoInfoFragment.java index f5545de..2d74e0f 100644 --- a/app/src/main/java/org/xbmc/kore/ui/sections/audio/MusicVideoInfoFragment.java +++ b/app/src/main/java/org/xbmc/kore/ui/sections/audio/MusicVideoInfoFragment.java @@ -27,7 +27,6 @@ import android.support.v4.content.CursorLoader; import android.support.v4.content.Loader; import android.support.v7.app.AlertDialog; import android.view.View; -import android.widget.ImageButton; import android.widget.Toast; import org.xbmc.kore.R; @@ -40,6 +39,7 @@ import org.xbmc.kore.service.library.LibrarySyncService; import org.xbmc.kore.ui.AbstractAdditionalInfoFragment; import org.xbmc.kore.ui.AbstractInfoFragment; import org.xbmc.kore.ui.generic.RefreshItem; +import org.xbmc.kore.ui.widgets.fabspeeddial.FABSpeedDial; import org.xbmc.kore.utils.FileDownloadHelper; import org.xbmc.kore.utils.LogUtils; import org.xbmc.kore.utils.UIUtils; @@ -107,13 +107,13 @@ public class MusicVideoInfoFragment extends AbstractInfoFragment } @Override - protected boolean setupFAB(ImageButton FAB) { - FAB.setOnClickListener(new View.OnClickListener() { + protected boolean setupFAB(FABSpeedDial FAB) { + FAB.setOnFabClickListener(new View.OnClickListener() { @Override public void onClick(View v) { PlaylistType.Item item = new PlaylistType.Item(); item.musicvideoid = getDataHolder().getId(); - fabActionPlayItem(item); + playItemOnKodi(item); } }); return true; diff --git a/app/src/main/java/org/xbmc/kore/ui/sections/video/MovieInfoFragment.java b/app/src/main/java/org/xbmc/kore/ui/sections/video/MovieInfoFragment.java index 77458a8..3554e97 100644 --- a/app/src/main/java/org/xbmc/kore/ui/sections/video/MovieInfoFragment.java +++ b/app/src/main/java/org/xbmc/kore/ui/sections/video/MovieInfoFragment.java @@ -28,7 +28,6 @@ import android.support.v4.content.Loader; import android.support.v7.app.AlertDialog; import android.text.TextUtils; import android.view.View; -import android.widget.ImageButton; import android.widget.Toast; import org.xbmc.kore.R; @@ -44,6 +43,7 @@ import org.xbmc.kore.ui.AbstractAdditionalInfoFragment; import org.xbmc.kore.ui.AbstractInfoFragment; import org.xbmc.kore.ui.generic.CastFragment; import org.xbmc.kore.ui.generic.RefreshItem; +import org.xbmc.kore.ui.widgets.fabspeeddial.FABSpeedDial; import org.xbmc.kore.utils.FileDownloadHelper; import org.xbmc.kore.utils.LogUtils; import org.xbmc.kore.utils.Utils; @@ -241,13 +241,18 @@ public class MovieInfoFragment extends AbstractInfoFragment } @Override - protected boolean setupFAB(ImageButton FAB) { - FAB.setOnClickListener(new View.OnClickListener() { + protected boolean setupFAB(final FABSpeedDial FAB) { + FAB.setOnDialItemClickListener(new FABSpeedDial.DialListener() { @Override - public void onClick(View v) { + public void onLocalPlayClicked() { + playItemLocally(movieDownloadInfo.getMediaUrl(getHostInfo()), "video/*"); + } + + @Override + public void onRemotePlayClicked() { PlaylistType.Item item = new PlaylistType.Item(); item.movieid = getDataHolder().getId(); - fabActionPlayItem(item); + playItemOnKodi(item); } }); return true; @@ -332,6 +337,8 @@ public class MovieInfoFragment extends AbstractInfoFragment break; } } + + getFabButton().enableLocalPlay(movieDownloadInfo != null); } /** {@inheritDoc} */ diff --git a/app/src/main/java/org/xbmc/kore/ui/sections/video/TVShowEpisodeInfoFragment.java b/app/src/main/java/org/xbmc/kore/ui/sections/video/TVShowEpisodeInfoFragment.java index 1c8a429..dc028d4 100644 --- a/app/src/main/java/org/xbmc/kore/ui/sections/video/TVShowEpisodeInfoFragment.java +++ b/app/src/main/java/org/xbmc/kore/ui/sections/video/TVShowEpisodeInfoFragment.java @@ -28,7 +28,6 @@ import android.support.v4.content.Loader; import android.support.v7.app.AlertDialog; import android.text.TextUtils; import android.view.View; -import android.widget.ImageButton; import org.xbmc.kore.R; import org.xbmc.kore.jsonrpc.ApiCallback; @@ -40,6 +39,7 @@ import org.xbmc.kore.service.library.LibrarySyncService; import org.xbmc.kore.ui.AbstractAdditionalInfoFragment; import org.xbmc.kore.ui.AbstractInfoFragment; import org.xbmc.kore.ui.generic.RefreshItem; +import org.xbmc.kore.ui.widgets.fabspeeddial.FABSpeedDial; import org.xbmc.kore.utils.FileDownloadHelper; import org.xbmc.kore.utils.LogUtils; import org.xbmc.kore.utils.Utils; @@ -134,13 +134,18 @@ public class TVShowEpisodeInfoFragment extends AbstractInfoFragment } @Override - protected boolean setupFAB(ImageButton FAB) { - FAB.setOnClickListener(new View.OnClickListener() { + protected boolean setupFAB(FABSpeedDial FAB) { + FAB.setOnDialItemClickListener(new FABSpeedDial.DialListener() { @Override - public void onClick(View v) { + public void onLocalPlayClicked() { + playItemLocally(fileDownloadHelper.getMediaUrl(getHostInfo()), "video/*"); + } + + @Override + public void onRemotePlayClicked() { PlaylistType.Item item = new PlaylistType.Item(); item.episodeid = getDataHolder().getId(); - fabActionPlayItem(item); + playItemOnKodi(item); } }); return true; @@ -198,10 +203,10 @@ public class TVShowEpisodeInfoFragment extends AbstractInfoFragment director = getActivity().getResources().getString(R.string.directors) + " " + director; } int runtime = cursor.getInt(EpisodeDetailsQuery.RUNTIME) / 60; - String durationPremiered = runtime > 0 ? - String.format(getString(R.string.minutes_abbrev), String.valueOf(runtime)) + - " | " + cursor.getString(EpisodeDetailsQuery.FIRSTAIRED) : - cursor.getString(EpisodeDetailsQuery.FIRSTAIRED); + String durationPremiered = runtime > 0 ? + String.format(getString(R.string.minutes_abbrev), String.valueOf(runtime)) + + " | " + cursor.getString(EpisodeDetailsQuery.FIRSTAIRED) : + cursor.getString(EpisodeDetailsQuery.FIRSTAIRED); String season = String.format(getString(R.string.season_episode), cursor.getInt(EpisodeDetailsQuery.SEASON), cursor.getInt(EpisodeDetailsQuery.EPISODE)); @@ -228,6 +233,8 @@ public class TVShowEpisodeInfoFragment extends AbstractInfoFragment break; } } + + getFabButton().enableLocalPlay(fileDownloadHelper != null); } /** {@inheritDoc} */ @@ -237,13 +244,6 @@ public class TVShowEpisodeInfoFragment extends AbstractInfoFragment } private void downloadEpisode() { - final FileDownloadHelper.TVShowInfo tvshowDownloadInfo = new FileDownloadHelper.TVShowInfo( - cursor.getString(EpisodeDetailsQuery.SHOWTITLE), - cursor.getInt(EpisodeDetailsQuery.SEASON), - cursor.getInt(EpisodeDetailsQuery.EPISODE), - cursor.getString(EpisodeDetailsQuery.TITLE), - cursor.getString(EpisodeDetailsQuery.FILE)); - DialogInterface.OnClickListener noopClickListener = new DialogInterface.OnClickListener() { @Override @@ -251,7 +251,7 @@ public class TVShowEpisodeInfoFragment extends AbstractInfoFragment }; // Check if the directory exists and whether to overwrite it - File file = new File(tvshowDownloadInfo.getAbsoluteFilePath()); + File file = new File(fileDownloadHelper.getAbsoluteFilePath()); if (file.exists()) { AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); builder.setTitle(R.string.download) @@ -261,7 +261,7 @@ public class TVShowEpisodeInfoFragment extends AbstractInfoFragment @Override public void onClick(DialogInterface dialog, int which) { FileDownloadHelper.downloadFiles(getActivity(), getHostInfo(), - tvshowDownloadInfo, FileDownloadHelper.OVERWRITE_FILES, + fileDownloadHelper, FileDownloadHelper.OVERWRITE_FILES, callbackHandler); } }) @@ -270,7 +270,7 @@ public class TVShowEpisodeInfoFragment extends AbstractInfoFragment @Override public void onClick(DialogInterface dialog, int which) { FileDownloadHelper.downloadFiles(getActivity(), getHostInfo(), - tvshowDownloadInfo, FileDownloadHelper.DOWNLOAD_WITH_NEW_NAME, + fileDownloadHelper, FileDownloadHelper.DOWNLOAD_WITH_NEW_NAME, callbackHandler); } }) @@ -286,7 +286,7 @@ public class TVShowEpisodeInfoFragment extends AbstractInfoFragment @Override public void onClick(DialogInterface dialog, int which) { FileDownloadHelper.downloadFiles(getActivity(), getHostInfo(), - tvshowDownloadInfo, FileDownloadHelper.OVERWRITE_FILES, + fileDownloadHelper, FileDownloadHelper.OVERWRITE_FILES, callbackHandler); } }) diff --git a/app/src/main/java/org/xbmc/kore/ui/sections/video/TVShowInfoFragment.java b/app/src/main/java/org/xbmc/kore/ui/sections/video/TVShowInfoFragment.java index 9c811d8..2228499 100644 --- a/app/src/main/java/org/xbmc/kore/ui/sections/video/TVShowInfoFragment.java +++ b/app/src/main/java/org/xbmc/kore/ui/sections/video/TVShowInfoFragment.java @@ -22,7 +22,6 @@ import android.provider.BaseColumns; import android.support.v4.app.LoaderManager; import android.support.v4.content.CursorLoader; import android.support.v4.content.Loader; -import android.widget.ImageButton; import org.xbmc.kore.R; import org.xbmc.kore.Settings; @@ -32,6 +31,7 @@ import org.xbmc.kore.service.library.LibrarySyncService; import org.xbmc.kore.ui.AbstractAdditionalInfoFragment; import org.xbmc.kore.ui.AbstractInfoFragment; import org.xbmc.kore.ui.generic.RefreshItem; +import org.xbmc.kore.ui.widgets.fabspeeddial.FABSpeedDial; import org.xbmc.kore.utils.LogUtils; /** @@ -72,7 +72,7 @@ public class TVShowInfoFragment extends AbstractInfoFragment } @Override - protected boolean setupFAB(ImageButton FAB) { + protected boolean setupFAB(FABSpeedDial FAB) { return false; } diff --git a/app/src/main/java/org/xbmc/kore/ui/widgets/fabspeeddial/DialActionButton.java b/app/src/main/java/org/xbmc/kore/ui/widgets/fabspeeddial/DialActionButton.java new file mode 100644 index 0000000..a0605c5 --- /dev/null +++ b/app/src/main/java/org/xbmc/kore/ui/widgets/fabspeeddial/DialActionButton.java @@ -0,0 +1,206 @@ +/* + * Copyright 2017 Martijn Brekhof. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.xbmc.kore.ui.widgets.fabspeeddial; + +import android.animation.Animator; +import android.animation.TimeInterpolator; +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.graphics.Interpolator; +import android.graphics.PorterDuff; +import android.graphics.drawable.Drawable; +import android.support.design.widget.FloatingActionButton; +import android.support.v7.content.res.AppCompatResources; +import android.support.v7.widget.AppCompatTextView; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.LinearLayout; + +import org.xbmc.kore.R; +import org.xbmc.kore.utils.Utils; + +import butterknife.ButterKnife; +import butterknife.InjectView; + +public class DialActionButton extends LinearLayout { + @InjectView(R.id.dial_label) AppCompatTextView label; + @InjectView(R.id.dial_action_button) FloatingActionButton button; + + private View anchorView; + private boolean isHiding; + private TimeInterpolator showInterpolator; + private TimeInterpolator hideInterpolator; + + public DialActionButton(Context context) { + this(context, null, 0); + } + + public DialActionButton(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public DialActionButton(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + initializeView(context, attrs, defStyleAttr); + } + + public void setShowInterpolator(TimeInterpolator showInterpolator) { + this.showInterpolator = showInterpolator; + } + + public void setHideInterpolator(TimeInterpolator hideInterpolator) { + this.hideInterpolator = hideInterpolator; + } + + /** + * Sets the View from which the DialActionButtons should appear or disappear. + * It uses the anchorView's animation duration to set the duration for + * the DialActionButton. + *
+ * Use {@link #setShowInterpolator(TimeInterpolator)} and + * {@link #setHideInterpolator(TimeInterpolator)} to set the appropriate interpolators + * for this DialActionButton + * @param anchorView + */ + public void setAnchorView(View anchorView) { + this.anchorView = anchorView; + + //Initialize animation + long anim_duration = anchorView.animate().getDuration(); + + label.setAlpha(0f); + label.animate().setDuration(anim_duration); + label.setScaleX(0f); + label.setScaleY(0f); + + animate().setDuration(anim_duration); + animate().setListener(new Animator.AnimatorListener() { + @Override + public void onAnimationStart(Animator animator) { + + } + + @Override + public void onAnimationEnd(Animator animator) { + if (isHiding) { + setVisibility(View.INVISIBLE); + } + } + + @Override + public void onAnimationCancel(Animator animator) { + + } + + @Override + public void onAnimationRepeat(Animator animator) { + + } + }); + } + + public void show() { + isHiding = false; + + setVisibility(View.VISIBLE); + + if (anchorView != null) { + setY(anchorView.getY()); + animate().translationY(0); + animate().setInterpolator(showInterpolator); + + label.animate().setInterpolator(showInterpolator); + label.setX(anchorView.getX()); + label.animate().translationX(0); + label.animate().alpha(1f); + label.animate().scaleX(1f); + label.animate().scaleY(1f); + } + } + + public void hide() { + if (isHiding) + return; + + if (anchorView == null) { + setVisibility(View.GONE); + } else { + isHiding = true; + animate().setInterpolator(hideInterpolator); + animate().translationY(anchorView.getY() - getY()); + + label.animate().setInterpolator(hideInterpolator); + label.animate().translationX(anchorView.getX() - label.getX()); + label.animate().alpha(0f); + label.animate().scaleX(0f); + label.animate().scaleY(0f); + } + } + + public Drawable getDrawable() { + return button.getDrawable(); + } + + public AppCompatTextView getLabel() { + return label; + } + + public void setColorFilter(int color) { + button.getBackground().setColorFilter(color, PorterDuff.Mode.SRC_IN); + label.getBackground().setColorFilter(color, PorterDuff.Mode.SRC_IN); + } + + private void initializeView(Context context, AttributeSet attrs, int defStyleAttr) { + LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + View view = inflater.inflate(R.layout.dial_action_button, this); + ButterKnife.inject(view); + + // Make sure shadow is not clipped + setClipToPadding(false); + + // Make sure translation animations do not cause clipping + // by parent view group when moving outside its boundaries. + // For example, when using the overshoot interpolator. + setClipChildren(false); + + Resources.Theme theme = getContext().getTheme(); + TypedArray typedArray = theme.obtainStyledAttributes(attrs, new int[]{android.R.attr.text, + R.attr.iconFABDial}, + defStyleAttr, + 0); + String text = typedArray.getString(0); + + if (text != null) { + label.setText(text); + } else { + label.setVisibility(View.GONE); + } + + TypedValue typedValue = new TypedValue(); + typedArray.getValue(1, typedValue); + button.setImageResource(typedValue.resourceId); + + typedArray.recycle(); + + ColorStateList colorStateList = AppCompatResources.getColorStateList(context, R.color.fabspeeddial); + button.setBackgroundTintList(colorStateList); + } +} diff --git a/app/src/main/java/org/xbmc/kore/ui/widgets/fabspeeddial/FABSpeedDial.java b/app/src/main/java/org/xbmc/kore/ui/widgets/fabspeeddial/FABSpeedDial.java new file mode 100644 index 0000000..07d289a --- /dev/null +++ b/app/src/main/java/org/xbmc/kore/ui/widgets/fabspeeddial/FABSpeedDial.java @@ -0,0 +1,362 @@ +/* + * Copyright 2017 Martijn Brekhof. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.xbmc.kore.ui.widgets.fabspeeddial; + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.os.Parcelable; +import android.preference.PreferenceManager; +import android.support.annotation.Nullable; +import android.support.design.widget.FloatingActionButton; +import android.support.v7.content.res.AppCompatResources; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.view.LayoutInflater; +import android.view.View; +import android.view.animation.AccelerateInterpolator; +import android.view.animation.OvershootInterpolator; +import android.widget.LinearLayout; + +import org.xbmc.kore.R; +import org.xbmc.kore.Settings; +import org.xbmc.kore.ui.animators.ChangeImageFadeAnimation; +import org.xbmc.kore.ui.animators.PulsateAnimation; + +import butterknife.ButterKnife; +import butterknife.InjectView; + +/** + * The Floating Action Button Speed Dial uses a {@link FloatingActionButton} and can + * optionally show a speed dial menu. To enable the speed dials add a listener + * for the dials using {@link #setOnDialItemClickListener(DialListener)}. + * + *

The icons for the FAB needs to be set through your theme: + *

    + *
  • org.xbmc.kore.R.attr.iconFABDefault sets the icon when the dials are disabled
  • + *
  • org.xbmc.kore.R.attr.iconFABDialsOpenClose sets the icon when the dials are enabled
  • + *
+ *

+ * + *

+ * The background color can be set through your theme: + *

    + *
  • org.xbmc.kore.R.attr.fabColorNormal sets the default color
  • + *
  • org.xbmc.kore.R.attr.fabColorPressed sets the pressed state color
  • + *
  • org.xbmc.kore.R.attr.fabColorFocus sets the focus state color
  • + *
+ *

+ */ +public class FABSpeedDial extends LinearLayout { + @InjectView(R.id.fabspeeddial) FloatingActionButton FABMain; + @InjectView(R.id.play_local) DialActionButton FABPlayLocal; + @InjectView(R.id.play_remote) DialActionButton FABPlayRemote; + + private final String BUNDLE_KEY_EXPANDED = "expanded"; + private final String BUNDLE_KEY_PARENT = "parent"; + private final String BUNDLE_KEY_DIALCLICKED = "dialclicked"; + + private PulsateAnimation busyAnimation; + private DialActionButton dialSelected; + private boolean dialsVisible; + private boolean dialsEnabled; + + private Drawable iconFABDefault; + private Drawable iconFABOpenClose; + + private OvershootInterpolator showDialsInterpolator = new OvershootInterpolator(); + private AccelerateInterpolator hideDialsInterpolator = new AccelerateInterpolator(); + + public interface DialListener { + void onLocalPlayClicked(); + void onRemotePlayClicked(); + } + + private DialListener dialListener; + private OnClickListener fabListener; + + public FABSpeedDial(Context context) { + this(context, null); + } + + public FABSpeedDial(Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public FABSpeedDial(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + initializeView(context); + } + + @TargetApi(21) + public FABSpeedDial(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + initializeView(context); + } + + /** + * Enables/disables the speed dials. This means that if enabled, + * the dials will be shown if the user pressed the FAB button. + * @param enable true to enable the dials, false to disable + * @param animate true to use animation to change FAB icon, false to instantly change the FAB icon + */ + public void enableSpeedDials(boolean enable, boolean animate) { + if (dialsEnabled == enable) + return; + + dialsEnabled = enable; + + changeFABIcon(animate); + } + + /** + * Add listener to handle dial button click events. + *
+ * Note: adding a listener for the dials also enables the speed dials if + * user didn't disable usage in settings + * @param dialListener + */ + public void setOnDialItemClickListener(DialListener dialListener) { + this.dialListener = dialListener; + + // Disable speed dials if user disabled it through settings + boolean disable = PreferenceManager + .getDefaultSharedPreferences(getContext()) + .getBoolean(Settings.KEY_PREF_DISABLE_LOCAL_PLAY, + Settings.DEFAULT_PREF_DISABLE_LOCAL_PLAY); + + enableSpeedDials(!disable, false); + } + + /** + * Add listener to handle FAB click events. + *
+ * Note: if the speed dials are enabled this won't be called + * when the FAB button is pressed. + * @param fabListener + */ + public void setOnFabClickListener(OnClickListener fabListener) { + this.fabListener = fabListener; + } + + /** + * WARNING: Do not use this to set a listener for the FAB button. + * Use {@link #setOnFabClickListener(OnClickListener)} + * instead. + *
+ * {@inheritDoc} + * @param l + */ + @Override + public void setOnClickListener(@Nullable OnClickListener l) { + super.setOnClickListener(l); + } + + /** + * Enables/disables the FAB button and starts/stops the busy animation + * @param enable true to disable the FAB button and start the busy animation, false to enable + * the FAB button and stop the busy animation. + */ + public void enableBusyAnimation(boolean enable) { + if (enable) { + busyAnimation.start(); + if (dialSelected != null) { + changeFABIcon(FABMain.getDrawable(), dialSelected.getDrawable()); + } + FABMain.setEnabled(false); + } else { + busyAnimation.stop(); + if (dialSelected != null) { + changeFABIcon(true); + dialSelected = null; + } + FABMain.setEnabled(true); + } + } + + public boolean busyAnimationIsEnabled() { + return busyAnimation.isRunning(); + } + + public void enableLocalPlay(boolean enable) { + FABPlayLocal.setEnabled(enable); + } + + public void showDials(boolean show) { + dialsVisible = show; + + if (show) { + FABMain.animate().setInterpolator(showDialsInterpolator); + FABMain.animate().rotation(-45f); + FABPlayLocal.show(); + FABPlayRemote.show(); + } else { + FABMain.animate().setInterpolator(hideDialsInterpolator); + FABMain.animate().rotation(0f); + FABPlayLocal.hide(); + FABPlayRemote.hide(); + } + } + + @Nullable + @Override + protected Parcelable onSaveInstanceState() { + Bundle bundle = new Bundle(); + bundle.putParcelable(BUNDLE_KEY_PARENT, super.onSaveInstanceState()); + bundle.putBoolean(BUNDLE_KEY_EXPANDED, dialsVisible); + if (dialSelected != null) { + bundle.putCharSequence(BUNDLE_KEY_DIALCLICKED, dialSelected.getLabel().getText()); + } + return bundle; + } + + @Override + protected void onRestoreInstanceState(Parcelable state) { + if (state != null) { + Bundle bundle = (Bundle) state; + + super.onRestoreInstanceState(bundle.getParcelable(BUNDLE_KEY_PARENT)); + showDials(bundle.getBoolean(BUNDLE_KEY_EXPANDED)); + + CharSequence charSequence = bundle.getCharSequence(BUNDLE_KEY_DIALCLICKED); + if ((charSequence != null) && (! charSequence.equals(FABPlayLocal.getLabel().getText()))) { + dialSelected = FABPlayRemote; + + enableBusyAnimation(true); + } + } + } + + private void initializeView(Context context) { + LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + View view = inflater.inflate(R.layout.fab_speed_dial, this); + + ButterKnife.inject(view); + + // Makes sure shadow is not clipped + setClipToPadding(false); + + // Makes sure translation animations do not cause clipping + // by parent view group when moving outside its boundaries. + // For example, when using the overshoot interpolator. + setClipChildren(false); + + setupListeners(); + + setupFABIcon(context); + setupDial(FABPlayLocal); + setupDial(FABPlayRemote); + } + + private void setupFABIcon(Context context) { + TypedValue tv = new TypedValue(); + + context.getTheme().resolveAttribute(R.attr.iconFABDialsOpenClose, tv, false); + iconFABOpenClose = AppCompatResources.getDrawable(context, tv.data); + context.getTheme().resolveAttribute(R.attr.iconFABDefault, tv, false); + iconFABDefault = AppCompatResources.getDrawable(context, tv.data); + + FABMain.setImageDrawable(dialsEnabled ? iconFABOpenClose : iconFABDefault); + + ColorStateList colorStateList = AppCompatResources.getColorStateList(context, R.color.fabspeeddial); + int fabColorNormal = colorStateList.getColorForState(new int[] {android.R.attr.state_enabled}, + R.attr.colorPrimaryDark); + int fabColorPressed = colorStateList.getColorForState(new int[] {android.R.attr.state_pressed}, + R.attr.colorPrimary); + + busyAnimation = new PulsateAnimation(FABMain, fabColorNormal, fabColorPressed); + + FABMain.setBackgroundTintList(colorStateList); + } + + private void setupDial(DialActionButton dialActionButton) { + dialActionButton.setAnchorView(FABMain); + dialActionButton.setShowInterpolator(showDialsInterpolator); + dialActionButton.setHideInterpolator(hideDialsInterpolator); + } + + private void setupListeners() { + FABMain.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + if (dialsEnabled) { + showDials(!FABPlayLocal.isShown()); + } else { + if (fabListener != null) { + fabListener.onClick(v); + } else if (dialListener != null) { + /** + * We take remote play as default and we try to fallback if dev misconfigured + * the FAB in {@link org.xbmc.kore.ui.AbstractInfoFragment#setupFAB(FABSpeedDial)}. + * This is also needed to support disabling local playback through settings. + */ + dialListener.onRemotePlayClicked(); + } + } + } + }); + + FABPlayLocal.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + dialSelected = FABPlayLocal; + if (dialListener != null) { + dialListener.onLocalPlayClicked(); + showDials(false); + } + } + }); + + + FABPlayRemote.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + dialSelected = FABPlayRemote; + if (dialListener != null) { + dialListener.onRemotePlayClicked(); + showDials(false); + } + } + }); + } + + private ChangeImageFadeAnimation changeImageFadeAnimation; + + private void changeFABIcon(final Drawable from, final Drawable to) { + // Cancel previous animation if any + if (changeImageFadeAnimation != null) + changeImageFadeAnimation.cancel(); + + changeImageFadeAnimation = new ChangeImageFadeAnimation(FABMain, from, to); + changeImageFadeAnimation.start(); + } + + /** + * Changes the FAB icon to its default value. + * @param animate true to use an animation to change the icon, false to change it instantly + */ + private void changeFABIcon(boolean animate) { + Drawable drawable = dialsEnabled ? iconFABOpenClose : iconFABDefault; + + if (animate) { + changeFABIcon(FABMain.getDrawable(), drawable); + } else { + FABMain.setImageDrawable(drawable); + } + } +} diff --git a/app/src/main/res/color/fabspeeddial.xml b/app/src/main/res/color/fabspeeddial.xml new file mode 100644 index 0000000..18fbb63 --- /dev/null +++ b/app/src/main/res/color/fabspeeddial.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_cellphone_android_white_24dp.xml b/app/src/main/res/drawable/ic_cellphone_android_white_24dp.xml new file mode 100644 index 0000000..1637dd4 --- /dev/null +++ b/app/src/main/res/drawable/ic_cellphone_android_white_24dp.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_plus_white_24dp.xml b/app/src/main/res/drawable/ic_plus_white_24dp.xml new file mode 100644 index 0000000..0c615ff --- /dev/null +++ b/app/src/main/res/drawable/ic_plus_white_24dp.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rounded_corners_shape.xml b/app/src/main/res/drawable/rounded_corners_shape.xml new file mode 100644 index 0000000..b68c708 --- /dev/null +++ b/app/src/main/res/drawable/rounded_corners_shape.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dial_action_button.xml b/app/src/main/res/layout/dial_action_button.xml new file mode 100644 index 0000000..a1232d5 --- /dev/null +++ b/app/src/main/res/layout/dial_action_button.xml @@ -0,0 +1,24 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fab_speed_dial.xml b/app/src/main/res/layout/fab_speed_dial.xml new file mode 100644 index 0000000..1ce75d0 --- /dev/null +++ b/app/src/main/res/layout/fab_speed_dial.xml @@ -0,0 +1,31 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_info.xml b/app/src/main/res/layout/fragment_info.xml index 3355340..5428578 100644 --- a/app/src/main/res/layout/fragment_info.xml +++ b/app/src/main/res/layout/fragment_info.xml @@ -15,11 +15,12 @@ limitations under the License. --> - + android:layout_height="match_parent" + android:clipChildren="false"> - - - - + - @@ -277,4 +268,4 @@ android:layout_height="match_parent" android:background="?attr/fabColorNormal" android:visibility="invisible"/> - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/values-v19/themes.xml b/app/src/main/res/values-v19/themes.xml deleted file mode 100644 index 63b996b..0000000 --- a/app/src/main/res/values-v19/themes.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values/attr.xml b/app/src/main/res/values/attr.xml index 8f45760..0831d69 100644 --- a/app/src/main/res/values/attr.xml +++ b/app/src/main/res/values/attr.xml @@ -31,6 +31,7 @@ + @@ -50,6 +51,7 @@ + @@ -114,7 +116,12 @@ + + + + - \ No newline at end of file + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index be8effc..e83717f 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -23,6 +23,7 @@ @color/orange_A400 @color/orange_A700 + @color/orange_A100 #ffffffff #88ffffff @@ -32,6 +33,7 @@ #8a000000 #0f85a5 + #17cdff #0a5b71 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index eb8a14b..1a19287 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -423,5 +423,8 @@ 0:00 Expand/Collapse + Play Locally + Disable local playback support + Disables support for playing media locally on the device running Kore. diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index b2b08f8..9f0bb1a 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -14,7 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. --> - + + +