1/* 2 * Copyright (C) 2013 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17package android.support.v7.app; 18 19import android.app.Activity; 20import android.content.Context; 21import android.content.ContextWrapper; 22import android.content.res.TypedArray; 23import android.graphics.Canvas; 24import android.graphics.Rect; 25import android.graphics.drawable.AnimationDrawable; 26import android.graphics.drawable.Drawable; 27import android.support.annotation.NonNull; 28import android.support.v4.app.FragmentActivity; 29import android.support.v4.app.FragmentManager; 30import android.support.v4.graphics.drawable.DrawableCompat; 31import android.support.v4.view.GravityCompat; 32import android.support.v7.media.MediaRouter; 33import android.support.v7.media.MediaRouteSelector; 34import android.support.v7.mediarouter.R; 35import android.text.TextUtils; 36import android.util.AttributeSet; 37import android.util.Log; 38import android.view.Gravity; 39import android.view.HapticFeedbackConstants; 40import android.view.SoundEffectConstants; 41import android.view.View; 42import android.widget.Toast; 43 44/** 45 * The media route button allows the user to select routes and to control the 46 * currently selected route. 47 * <p> 48 * The application must specify the kinds of routes that the user should be allowed 49 * to select by specifying a {@link MediaRouteSelector selector} with the 50 * {@link #setRouteSelector} method. 51 * </p><p> 52 * When the default route is selected or when the currently selected route does not 53 * match the {@link #getRouteSelector() selector}, the button will appear in 54 * an inactive state indicating that the application is not connected to a 55 * route of the kind that it wants to use. Clicking on the button opens 56 * a {@link MediaRouteChooserDialog} to allow the user to select a route. 57 * If no non-default routes match the selector and it is not possible for an active 58 * scan to discover any matching routes, then the button is disabled and cannot 59 * be clicked. 60 * </p><p> 61 * When a non-default route is selected that matches the selector, the button will 62 * appear in an active state indicating that the application is connected 63 * to a route of the kind that it wants to use. The button may also appear 64 * in an intermediary connecting state if the route is in the process of connecting 65 * to the destination but has not yet completed doing so. In either case, clicking 66 * on the button opens a {@link MediaRouteControllerDialog} to allow the user 67 * to control or disconnect from the current route. 68 * </p> 69 * 70 * <h3>Prerequisites</h3> 71 * <p> 72 * To use the media route button, the activity must be a subclass of 73 * {@link FragmentActivity} from the <code>android.support.v4</code> 74 * support library. Refer to support library documentation for details. 75 * </p> 76 * 77 * @see MediaRouteActionProvider 78 * @see #setRouteSelector 79 */ 80public class MediaRouteButton extends View { 81 private static final String TAG = "MediaRouteButton"; 82 83 private static final String CHOOSER_FRAGMENT_TAG = 84 "android.support.v7.mediarouter:MediaRouteChooserDialogFragment"; 85 private static final String CONTROLLER_FRAGMENT_TAG = 86 "android.support.v7.mediarouter:MediaRouteControllerDialogFragment"; 87 88 private final MediaRouter mRouter; 89 private final MediaRouterCallback mCallback; 90 91 private MediaRouteSelector mSelector = MediaRouteSelector.EMPTY; 92 private MediaRouteDialogFactory mDialogFactory = MediaRouteDialogFactory.getDefault(); 93 94 private boolean mAttachedToWindow; 95 96 private Drawable mRemoteIndicator; 97 private boolean mRemoteActive; 98 private boolean mCheatSheetEnabled; 99 private boolean mIsConnecting; 100 101 private int mMinWidth; 102 private int mMinHeight; 103 104 // The checked state is used when connected to a remote route. 105 private static final int[] CHECKED_STATE_SET = { 106 android.R.attr.state_checked 107 }; 108 109 // The checkable state is used while connecting to a remote route. 110 private static final int[] CHECKABLE_STATE_SET = { 111 android.R.attr.state_checkable 112 }; 113 114 public MediaRouteButton(Context context) { 115 this(context, null); 116 } 117 118 public MediaRouteButton(Context context, AttributeSet attrs) { 119 this(context, attrs, R.attr.mediaRouteButtonStyle); 120 } 121 122 public MediaRouteButton(Context context, AttributeSet attrs, int defStyleAttr) { 123 super(MediaRouterThemeHelper.createThemedContext(context), attrs, defStyleAttr); 124 context = getContext(); 125 126 mRouter = MediaRouter.getInstance(context); 127 mCallback = new MediaRouterCallback(); 128 129 TypedArray a = context.obtainStyledAttributes(attrs, 130 R.styleable.MediaRouteButton, defStyleAttr, 0); 131 setRemoteIndicatorDrawable(a.getDrawable( 132 R.styleable.MediaRouteButton_externalRouteEnabledDrawable)); 133 mMinWidth = a.getDimensionPixelSize( 134 R.styleable.MediaRouteButton_android_minWidth, 0); 135 mMinHeight = a.getDimensionPixelSize( 136 R.styleable.MediaRouteButton_android_minHeight, 0); 137 a.recycle(); 138 139 setClickable(true); 140 setLongClickable(true); 141 } 142 143 /** 144 * Gets the media route selector for filtering the routes that the user can 145 * select using the media route chooser dialog. 146 * 147 * @return The selector, never null. 148 */ 149 @NonNull 150 public MediaRouteSelector getRouteSelector() { 151 return mSelector; 152 } 153 154 /** 155 * Sets the media route selector for filtering the routes that the user can 156 * select using the media route chooser dialog. 157 * 158 * @param selector The selector, must not be null. 159 */ 160 public void setRouteSelector(MediaRouteSelector selector) { 161 if (selector == null) { 162 throw new IllegalArgumentException("selector must not be null"); 163 } 164 165 if (!mSelector.equals(selector)) { 166 if (mAttachedToWindow) { 167 if (!mSelector.isEmpty()) { 168 mRouter.removeCallback(mCallback); 169 } 170 if (!selector.isEmpty()) { 171 mRouter.addCallback(selector, mCallback); 172 } 173 } 174 mSelector = selector; 175 refreshRoute(); 176 } 177 } 178 179 /** 180 * Gets the media route dialog factory to use when showing the route chooser 181 * or controller dialog. 182 * 183 * @return The dialog factory, never null. 184 */ 185 @NonNull 186 public MediaRouteDialogFactory getDialogFactory() { 187 return mDialogFactory; 188 } 189 190 /** 191 * Sets the media route dialog factory to use when showing the route chooser 192 * or controller dialog. 193 * 194 * @param factory The dialog factory, must not be null. 195 */ 196 public void setDialogFactory(@NonNull MediaRouteDialogFactory factory) { 197 if (factory == null) { 198 throw new IllegalArgumentException("factory must not be null"); 199 } 200 201 mDialogFactory = factory; 202 } 203 204 /** 205 * Show the route chooser or controller dialog. 206 * <p> 207 * If the default route is selected or if the currently selected route does 208 * not match the {@link #getRouteSelector selector}, then shows the route chooser dialog. 209 * Otherwise, shows the route controller dialog to offer the user 210 * a choice to disconnect from the route or perform other control actions 211 * such as setting the route's volume. 212 * </p><p> 213 * The application can customize the dialogs by calling {@link #setDialogFactory} 214 * to provide a customized dialog factory. 215 * </p> 216 * 217 * @return True if the dialog was actually shown. 218 * 219 * @throws IllegalStateException if the activity is not a subclass of 220 * {@link FragmentActivity}. 221 */ 222 public boolean showDialog() { 223 if (!mAttachedToWindow) { 224 return false; 225 } 226 227 final FragmentManager fm = getFragmentManager(); 228 if (fm == null) { 229 throw new IllegalStateException("The activity must be a subclass of FragmentActivity"); 230 } 231 232 MediaRouter.RouteInfo route = mRouter.getSelectedRoute(); 233 if (route.isDefault() || !route.matchesSelector(mSelector)) { 234 if (fm.findFragmentByTag(CHOOSER_FRAGMENT_TAG) != null) { 235 Log.w(TAG, "showDialog(): Route chooser dialog already showing!"); 236 return false; 237 } 238 MediaRouteChooserDialogFragment f = 239 mDialogFactory.onCreateChooserDialogFragment(); 240 f.setRouteSelector(mSelector); 241 f.show(fm, CHOOSER_FRAGMENT_TAG); 242 } else { 243 if (fm.findFragmentByTag(CONTROLLER_FRAGMENT_TAG) != null) { 244 Log.w(TAG, "showDialog(): Route controller dialog already showing!"); 245 return false; 246 } 247 MediaRouteControllerDialogFragment f = 248 mDialogFactory.onCreateControllerDialogFragment(); 249 f.show(fm, CONTROLLER_FRAGMENT_TAG); 250 } 251 return true; 252 } 253 254 private FragmentManager getFragmentManager() { 255 Activity activity = getActivity(); 256 if (activity instanceof FragmentActivity) { 257 return ((FragmentActivity)activity).getSupportFragmentManager(); 258 } 259 return null; 260 } 261 262 private Activity getActivity() { 263 // Gross way of unwrapping the Activity so we can get the FragmentManager 264 Context context = getContext(); 265 while (context instanceof ContextWrapper) { 266 if (context instanceof Activity) { 267 return (Activity)context; 268 } 269 context = ((ContextWrapper)context).getBaseContext(); 270 } 271 return null; 272 } 273 274 /** 275 * Sets whether to enable showing a toast with the content descriptor of the 276 * button when the button is long pressed. 277 */ 278 void setCheatSheetEnabled(boolean enable) { 279 mCheatSheetEnabled = enable; 280 } 281 282 @Override 283 public boolean performClick() { 284 // Send the appropriate accessibility events and call listeners 285 boolean handled = super.performClick(); 286 if (!handled) { 287 playSoundEffect(SoundEffectConstants.CLICK); 288 } 289 return showDialog() || handled; 290 } 291 292 @Override 293 public boolean performLongClick() { 294 if (super.performLongClick()) { 295 return true; 296 } 297 298 if (!mCheatSheetEnabled) { 299 return false; 300 } 301 302 final CharSequence contentDesc = getContentDescription(); 303 if (TextUtils.isEmpty(contentDesc)) { 304 // Don't show the cheat sheet if we have no description 305 return false; 306 } 307 308 final int[] screenPos = new int[2]; 309 final Rect displayFrame = new Rect(); 310 getLocationOnScreen(screenPos); 311 getWindowVisibleDisplayFrame(displayFrame); 312 313 final Context context = getContext(); 314 final int width = getWidth(); 315 final int height = getHeight(); 316 final int midy = screenPos[1] + height / 2; 317 final int screenWidth = context.getResources().getDisplayMetrics().widthPixels; 318 319 Toast cheatSheet = Toast.makeText(context, contentDesc, Toast.LENGTH_SHORT); 320 if (midy < displayFrame.height()) { 321 // Show along the top; follow action buttons 322 cheatSheet.setGravity(Gravity.TOP | GravityCompat.END, 323 screenWidth - screenPos[0] - width / 2, height); 324 } else { 325 // Show along the bottom center 326 cheatSheet.setGravity(Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL, 0, height); 327 } 328 cheatSheet.show(); 329 performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); 330 return true; 331 } 332 333 @Override 334 protected int[] onCreateDrawableState(int extraSpace) { 335 final int[] drawableState = super.onCreateDrawableState(extraSpace + 1); 336 337 // Technically we should be handling this more completely, but these 338 // are implementation details here. Checkable is used to express the connecting 339 // drawable state and it's mutually exclusive with check for the purposes 340 // of state selection here. 341 if (mIsConnecting) { 342 mergeDrawableStates(drawableState, CHECKABLE_STATE_SET); 343 } else if (mRemoteActive) { 344 mergeDrawableStates(drawableState, CHECKED_STATE_SET); 345 } 346 return drawableState; 347 } 348 349 @Override 350 protected void drawableStateChanged() { 351 super.drawableStateChanged(); 352 353 if (mRemoteIndicator != null) { 354 int[] myDrawableState = getDrawableState(); 355 mRemoteIndicator.setState(myDrawableState); 356 invalidate(); 357 } 358 } 359 360 /** 361 * Sets a drawable to use as the remote route indicator. 362 */ 363 public void setRemoteIndicatorDrawable(Drawable d) { 364 if (mRemoteIndicator != null) { 365 mRemoteIndicator.setCallback(null); 366 unscheduleDrawable(mRemoteIndicator); 367 } 368 mRemoteIndicator = d; 369 if (d != null) { 370 d.setCallback(this); 371 d.setState(getDrawableState()); 372 d.setVisible(getVisibility() == VISIBLE, false); 373 } 374 375 refreshDrawableState(); 376 } 377 378 @Override 379 protected boolean verifyDrawable(Drawable who) { 380 return super.verifyDrawable(who) || who == mRemoteIndicator; 381 } 382 383 //@Override defined in v11 384 public void jumpDrawablesToCurrentState() { 385 // We can't call super to handle the background so we do it ourselves. 386 //super.jumpDrawablesToCurrentState(); 387 if (getBackground() != null) { 388 DrawableCompat.jumpToCurrentState(getBackground()); 389 } 390 391 // Handle our own remote indicator. 392 if (mRemoteIndicator != null) { 393 DrawableCompat.jumpToCurrentState(mRemoteIndicator); 394 } 395 } 396 397 @Override 398 public void setVisibility(int visibility) { 399 super.setVisibility(visibility); 400 401 if (mRemoteIndicator != null) { 402 mRemoteIndicator.setVisible(getVisibility() == VISIBLE, false); 403 } 404 } 405 406 @Override 407 public void onAttachedToWindow() { 408 super.onAttachedToWindow(); 409 410 mAttachedToWindow = true; 411 if (!mSelector.isEmpty()) { 412 mRouter.addCallback(mSelector, mCallback); 413 } 414 refreshRoute(); 415 } 416 417 @Override 418 public void onDetachedFromWindow() { 419 mAttachedToWindow = false; 420 if (!mSelector.isEmpty()) { 421 mRouter.removeCallback(mCallback); 422 } 423 424 super.onDetachedFromWindow(); 425 } 426 427 @Override 428 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 429 final int widthSize = MeasureSpec.getSize(widthMeasureSpec); 430 final int heightSize = MeasureSpec.getSize(heightMeasureSpec); 431 final int widthMode = MeasureSpec.getMode(widthMeasureSpec); 432 final int heightMode = MeasureSpec.getMode(heightMeasureSpec); 433 434 final int minWidth = Math.max(mMinWidth, 435 mRemoteIndicator != null ? mRemoteIndicator.getIntrinsicWidth() : 0); 436 final int minHeight = Math.max(mMinHeight, 437 mRemoteIndicator != null ? mRemoteIndicator.getIntrinsicHeight() : 0); 438 439 int width; 440 switch (widthMode) { 441 case MeasureSpec.EXACTLY: 442 width = widthSize; 443 break; 444 case MeasureSpec.AT_MOST: 445 width = Math.min(widthSize, minWidth + getPaddingLeft() + getPaddingRight()); 446 break; 447 default: 448 case MeasureSpec.UNSPECIFIED: 449 width = minWidth + getPaddingLeft() + getPaddingRight(); 450 break; 451 } 452 453 int height; 454 switch (heightMode) { 455 case MeasureSpec.EXACTLY: 456 height = heightSize; 457 break; 458 case MeasureSpec.AT_MOST: 459 height = Math.min(heightSize, minHeight + getPaddingTop() + getPaddingBottom()); 460 break; 461 default: 462 case MeasureSpec.UNSPECIFIED: 463 height = minHeight + getPaddingTop() + getPaddingBottom(); 464 break; 465 } 466 467 setMeasuredDimension(width, height); 468 } 469 470 @Override 471 protected void onDraw(Canvas canvas) { 472 super.onDraw(canvas); 473 474 if (mRemoteIndicator != null) { 475 final int left = getPaddingLeft(); 476 final int right = getWidth() - getPaddingRight(); 477 final int top = getPaddingTop(); 478 final int bottom = getHeight() - getPaddingBottom(); 479 480 final int drawWidth = mRemoteIndicator.getIntrinsicWidth(); 481 final int drawHeight = mRemoteIndicator.getIntrinsicHeight(); 482 final int drawLeft = left + (right - left - drawWidth) / 2; 483 final int drawTop = top + (bottom - top - drawHeight) / 2; 484 485 mRemoteIndicator.setBounds(drawLeft, drawTop, 486 drawLeft + drawWidth, drawTop + drawHeight); 487 mRemoteIndicator.draw(canvas); 488 } 489 } 490 491 private void refreshRoute() { 492 if (mAttachedToWindow) { 493 final MediaRouter.RouteInfo route = mRouter.getSelectedRoute(); 494 final boolean isRemote = !route.isDefault() && route.matchesSelector(mSelector); 495 final boolean isConnecting = isRemote && route.isConnecting(); 496 497 boolean needsRefresh = false; 498 if (mRemoteActive != isRemote) { 499 mRemoteActive = isRemote; 500 needsRefresh = true; 501 } 502 if (mIsConnecting != isConnecting) { 503 mIsConnecting = isConnecting; 504 needsRefresh = true; 505 } 506 507 if (needsRefresh) { 508 refreshDrawableState(); 509 if (mIsConnecting && mRemoteIndicator.getCurrent() instanceof AnimationDrawable) { 510 AnimationDrawable curDrawable = 511 (AnimationDrawable) mRemoteIndicator.getCurrent(); 512 if (!curDrawable.isRunning()) { 513 curDrawable.start(); 514 } 515 } 516 } 517 518 setEnabled(mRouter.isRouteAvailable(mSelector, 519 MediaRouter.AVAILABILITY_FLAG_IGNORE_DEFAULT_ROUTE)); 520 } 521 } 522 523 private final class MediaRouterCallback extends MediaRouter.Callback { 524 @Override 525 public void onRouteAdded(MediaRouter router, MediaRouter.RouteInfo info) { 526 refreshRoute(); 527 } 528 529 @Override 530 public void onRouteRemoved(MediaRouter router, MediaRouter.RouteInfo info) { 531 refreshRoute(); 532 } 533 534 @Override 535 public void onRouteChanged(MediaRouter router, MediaRouter.RouteInfo info) { 536 refreshRoute(); 537 } 538 539 @Override 540 public void onRouteSelected(MediaRouter router, MediaRouter.RouteInfo info) { 541 refreshRoute(); 542 } 543 544 @Override 545 public void onRouteUnselected(MediaRouter router, MediaRouter.RouteInfo info) { 546 refreshRoute(); 547 } 548 549 @Override 550 public void onProviderAdded(MediaRouter router, MediaRouter.ProviderInfo provider) { 551 refreshRoute(); 552 } 553 554 @Override 555 public void onProviderRemoved(MediaRouter router, MediaRouter.ProviderInfo provider) { 556 refreshRoute(); 557 } 558 559 @Override 560 public void onProviderChanged(MediaRouter router, MediaRouter.ProviderInfo provider) { 561 refreshRoute(); 562 } 563 } 564} 565