1 /* 2 * Copyright (C) 2016 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 17 package com.android.car.radio; 18 19 import android.animation.ArgbEvaluator; 20 import android.animation.ValueAnimator; 21 import android.annotation.ColorInt; 22 import android.annotation.NonNull; 23 import android.annotation.Nullable; 24 import android.app.Activity; 25 import android.content.ComponentName; 26 import android.content.Context; 27 import android.content.Intent; 28 import android.content.ServiceConnection; 29 import android.graphics.Color; 30 import android.hardware.radio.ProgramSelector; 31 import android.hardware.radio.RadioManager; 32 import android.hardware.radio.RadioManager.ProgramInfo; 33 import android.hardware.radio.RadioMetadata; 34 import android.hardware.radio.RadioTuner; 35 import android.media.AudioManager; 36 import android.os.IBinder; 37 import android.os.RemoteException; 38 import android.util.Log; 39 import android.view.View; 40 41 import com.android.car.broadcastradio.support.Program; 42 import com.android.car.broadcastradio.support.platform.ProgramInfoExt; 43 import com.android.car.broadcastradio.support.platform.ProgramSelectorExt; 44 import com.android.car.radio.service.IRadioCallback; 45 import com.android.car.radio.service.IRadioManager; 46 import com.android.car.radio.storage.RadioStorage; 47 import com.android.car.radio.utils.ProgramSelectorUtils; 48 49 import java.util.ArrayList; 50 import java.util.List; 51 import java.util.Objects; 52 53 /** 54 * A controller that handles the display of metadata on the current radio station. 55 */ 56 public class RadioController implements RadioStorage.PresetsChangeListener { 57 private static final String TAG = "Em.RadioController"; 58 59 /** 60 * The percentage by which to darken the color that should be set on the status bar. 61 * This darkening gives the status bar the illusion that it is transparent. 62 * 63 * @see RadioController#setShouldColorStatusBar(boolean) 64 */ 65 private static final float STATUS_BAR_DARKEN_PERCENTAGE = 0.4f; 66 67 /** 68 * The animation time for when the background of the radio shifts to a different color. 69 */ 70 private static final int BACKGROUND_CHANGE_ANIM_TIME_MS = 450; 71 private static final int INVALID_BACKGROUND_COLOR = 0; 72 73 private static final int CHANNEL_CHANGE_DURATION_MS = 200; 74 75 private final ValueAnimator mAnimator = new ValueAnimator(); 76 private int mCurrentlyDisplayedChannel; // for animation purposes 77 private ProgramInfo mCurrentProgram; 78 79 private final Activity mActivity; 80 private IRadioManager mRadioManager; 81 82 private View mRadioBackground; 83 private boolean mShouldColorStatusBar; 84 private boolean mShouldColorBackground; 85 86 /** 87 * An additional layer on top of the background that should match the color of 88 * {@link #mRadioBackground}. This view should only exist in the preset list. The reason this 89 * layer cannot be transparent is because it needs to be elevated, and elevation does not 90 * work if the background is undefined or transparent. 91 */ 92 private View mRadioPresetBackground; 93 94 private View mRadioErrorDisplay; 95 96 private final RadioChannelColorMapper mColorMapper; 97 @ColorInt private int mCurrentBackgroundColor = INVALID_BACKGROUND_COLOR; 98 99 private final RadioDisplayController mRadioDisplayController; 100 101 /** 102 * Keeps track of if the user has manually muted the radio. This value is used to determine 103 * whether or not to un-mute the radio after an {@link AudioManager#AUDIOFOCUS_LOSS_TRANSIENT} 104 * event has been received. 105 */ 106 private boolean mUserHasMuted; 107 108 private final RadioStorage mRadioStorage; 109 110 private final String mAmBandString; 111 private final String mFmBandString; 112 113 private List<ProgramInfoChangeListener> mProgramInfoChangeListeners = new ArrayList<>(); 114 private List<RadioServiceConnectionListener> mRadioServiceConnectionListeners = 115 new ArrayList<>(); 116 117 /** 118 * Interface for a class that will be notified when the current radio station has been changed. 119 */ 120 public interface ProgramInfoChangeListener { 121 /** 122 * Called when the current radio station has changed in the radio. 123 * 124 * @param info The current radio station. 125 */ 126 void onProgramInfoChanged(@NonNull ProgramInfo info); 127 } 128 129 /** 130 * Interface for a class that will be notified when RadioService is successfuly bound 131 */ 132 public interface RadioServiceConnectionListener { 133 134 /** 135 * Called when the RadioService is successfully connected 136 */ 137 void onRadioServiceConnected(); 138 } 139 140 public RadioController(Activity activity) { 141 mActivity = activity; 142 143 mRadioDisplayController = new RadioDisplayController(mActivity); 144 mColorMapper = RadioChannelColorMapper.getInstance(mActivity); 145 146 mAmBandString = mActivity.getString(R.string.radio_am_text); 147 mFmBandString = mActivity.getString(R.string.radio_fm_text); 148 149 mRadioStorage = RadioStorage.getInstance(mActivity); 150 mRadioStorage.addPresetsChangeListener(this); 151 mShouldColorBackground = true; 152 } 153 154 /** 155 * Initializes this {@link RadioController} to control the UI whose root is the given container. 156 */ 157 public void initialize(View container) { 158 mCurrentBackgroundColor = INVALID_BACKGROUND_COLOR; 159 160 mRadioDisplayController.initialize(container); 161 162 mRadioDisplayController.setBackwardSeekButtonListener(mBackwardSeekClickListener); 163 mRadioDisplayController.setForwardSeekButtonListener(mForwardSeekClickListener); 164 mRadioDisplayController.setPlayButtonListener(mPlayPauseClickListener); 165 mRadioDisplayController.setAddPresetButtonListener(mPresetButtonClickListener); 166 167 mRadioBackground = container; 168 mRadioPresetBackground = container.findViewById(R.id.preset_current_card_container); 169 170 mRadioErrorDisplay = container.findViewById(R.id.radio_error_display); 171 172 updateRadioDisplay(); 173 } 174 175 /** 176 * Set whether or not this controller should also update the color of the status bar to match 177 * the current background color of the radio. The color that will be set on the status bar 178 * will be slightly darker, giving the illusion that the status bar is transparent. 179 * 180 * <p>This method is needed because of scene transitions. Scene transitions do not take into 181 * account padding that is added programmatically. Since there is no way to get the height of 182 * the status bar and set it in XML, it needs to be done in code. This breaks the scene 183 * transition. 184 * 185 * <p>To make this work, the status bar is not actually translucent; it is colored to appear 186 * that way via this method. 187 */ 188 public void setShouldColorStatusBar(boolean shouldColorStatusBar) { 189 mShouldColorStatusBar = shouldColorStatusBar; 190 } 191 192 /** 193 * Set whether this controller should update the background color. 194 * This behavior is enabled by defaullt 195 */ 196 public void setShouldColorBackground(boolean shouldColorBackground) { 197 mShouldColorBackground = shouldColorBackground; 198 } 199 200 /** 201 * Adds a listener that will be notified whenever the radio station changes. 202 */ 203 public void addProgramInfoChangeListener(ProgramInfoChangeListener listener) { 204 mProgramInfoChangeListeners.add(listener); 205 } 206 207 /** 208 * Removes a listener that will be notified whenever the radio station changes. 209 */ 210 public void removeProgramInfoChangeListener(ProgramInfoChangeListener listener) { 211 mProgramInfoChangeListeners.remove(listener); 212 } 213 214 /** 215 * Sets the listeners that will be notified when the radio service is connected. 216 */ 217 public void addRadioServiceConnectionListener(RadioServiceConnectionListener listener) { 218 mRadioServiceConnectionListeners.add(listener); 219 } 220 221 /** 222 * Removes a listener that will be notified when the radio service is connected. 223 */ 224 public void removeRadioServiceConnectionListener(RadioServiceConnectionListener listener) { 225 mRadioServiceConnectionListeners.remove(listener); 226 } 227 228 /** 229 * Starts the controller to handle radio tuning. This method should be called to begin 230 * radio playback. 231 */ 232 public void start() { 233 if (Log.isLoggable(TAG, Log.DEBUG)) { 234 Log.d(TAG, "starting radio"); 235 } 236 237 Intent bindIntent = new Intent(RadioService.ACTION_UI_SERVICE, null /* uri */, 238 mActivity, RadioService.class); 239 if (!mActivity.bindService(bindIntent, mServiceConnection, Context.BIND_AUTO_CREATE)) { 240 Log.e(TAG, "Failed to connect to RadioService."); 241 } 242 243 updateRadioDisplay(); 244 } 245 246 /** 247 * Retrieves information about the current radio station from {@link #mRadioManager} and updates 248 * the display of that information accordingly. 249 */ 250 private void updateRadioDisplay() { 251 if (mRadioManager == null) { 252 return; 253 } 254 255 try { 256 mRadioDisplayController.setSingleChannelDisplay(mRadioBackground); 257 258 // Ensure the play button properly reflects the current mute state. 259 mRadioDisplayController.setPlayPauseButtonState(mRadioManager.isMuted()); 260 261 // TODO(b/73950974): use callback only 262 ProgramInfo current = mRadioManager.getCurrentProgramInfo(); 263 if (current != null) mCallback.onCurrentProgramInfoChanged(current); 264 } catch (RemoteException e) { 265 Log.e(TAG, "updateRadioDisplay(); remote exception: " + e.getMessage()); 266 } 267 } 268 269 /** 270 * Tunes the radio to the given channel if it is valid and a {@link RadioTuner} has been opened. 271 */ 272 public void tune(ProgramSelector sel) { 273 if (mRadioManager == null) return; 274 275 try { 276 mRadioManager.tune(sel); 277 } catch (RemoteException ex) { 278 Log.e(TAG, "Failed to tune", ex); 279 } 280 } 281 282 /** 283 * Returns the band this radio is currently tuned to. 284 * 285 * TODO(b/73950974): don't be AM/FM exclusive 286 */ 287 public int getCurrentRadioBand() { 288 return ProgramSelectorUtils.getRadioBand(mCurrentProgram.getSelector()); 289 } 290 291 /** 292 * Returns the radio station that is currently playing on the radio. If this controller is 293 * not connected to the {@link RadioService} or a radio station cannot be retrieved, then 294 * {@code null} is returned. 295 * 296 * TODO(b/73950974): use callback only 297 */ 298 @Nullable 299 public ProgramInfo getCurrentProgramInfo() { 300 return mCurrentProgram; 301 } 302 303 /** 304 * Switch radio band. Currently, this only supports FM and AM bands. 305 * 306 * @param radioBand One of {@link RadioManager#BAND_FM}, {@link RadioManager#BAND_AM}. 307 */ 308 public void switchBand(int radioBand) { 309 try { 310 mRadioManager.switchBand(radioBand); 311 } catch (RemoteException e) { 312 Log.e(TAG, "Couldn't switch band", e); 313 } 314 } 315 316 /** 317 * Delegates to the {@link RadioDisplayController} to highlight the radio band. 318 */ 319 private void updateAmFmDisplayState(int band) { 320 switch (band) { 321 case RadioManager.BAND_FM: 322 mRadioDisplayController.setChannelBand(mFmBandString); 323 break; 324 325 case RadioManager.BAND_AM: 326 mRadioDisplayController.setChannelBand(mAmBandString); 327 break; 328 329 // TODO: Support BAND_FM_HD and BAND_AM_HD. 330 331 default: 332 mRadioDisplayController.setChannelBand(null); 333 } 334 } 335 336 // TODO(b/73950974): move channel animation to RadioDisplayController 337 private void updateRadioChannelDisplay(@NonNull ProgramSelector sel) { 338 int priType = sel.getPrimaryId().getType(); 339 340 mAnimator.cancel(); 341 342 if (!ProgramSelectorExt.isAmFmProgram(sel) 343 || !ProgramSelectorExt.hasId(sel, ProgramSelector.IDENTIFIER_TYPE_AMFM_FREQUENCY)) { 344 // channel animation is implemented for AM/FM only 345 mCurrentlyDisplayedChannel = 0; 346 mRadioDisplayController.setChannelNumber(""); 347 348 updateAmFmDisplayState(RadioStorage.INVALID_RADIO_BAND); 349 return; 350 } 351 352 int freq = (int)sel.getFirstId(ProgramSelector.IDENTIFIER_TYPE_AMFM_FREQUENCY); 353 354 boolean wasAm = ProgramSelectorExt.isAmFrequency(mCurrentlyDisplayedChannel); 355 boolean wasFm = ProgramSelectorExt.isFmFrequency(mCurrentlyDisplayedChannel); 356 boolean isAm = ProgramSelectorExt.isAmFrequency(freq); 357 int band = isAm ? RadioManager.BAND_AM : RadioManager.BAND_FM; 358 359 updateAmFmDisplayState(band); 360 361 if (isAm && wasAm || !isAm && wasFm) { 362 mAnimator.setIntValues((int)mCurrentlyDisplayedChannel, (int)freq); 363 mAnimator.setDuration(CHANNEL_CHANGE_DURATION_MS); 364 mAnimator.addUpdateListener(animation -> mRadioDisplayController.setChannelNumber( 365 ProgramSelectorExt.formatAmFmFrequency((int)animation.getAnimatedValue(), 366 ProgramSelectorExt.NAME_NO_MODULATION))); 367 mAnimator.start(); 368 } else { 369 // it's a different band - don't animate 370 mRadioDisplayController.setChannelNumber( 371 ProgramSelectorExt.getDisplayName(sel, ProgramSelectorExt.NAME_NO_MODULATION)); 372 } 373 mCurrentlyDisplayedChannel = freq; 374 375 maybeUpdateBackgroundColor(freq); 376 } 377 378 /** 379 * Checks if the color of the radio background should be changed, and if so, animates that 380 * color change. 381 */ 382 private void maybeUpdateBackgroundColor(int channel) { 383 if (mRadioBackground == null || !mShouldColorBackground) { 384 return; 385 } 386 387 int newColor = mColorMapper.getColorForChannel(channel); 388 389 // No animation required if the colors are the same. 390 if (newColor == mCurrentBackgroundColor) { 391 return; 392 } 393 394 // If the current background color is invalid, then just set as the new color without any 395 // animation. 396 if (mCurrentBackgroundColor == INVALID_BACKGROUND_COLOR) { 397 mCurrentBackgroundColor = newColor; 398 setBackgroundColor(newColor); 399 } 400 401 // Otherwise, animate the background color change. 402 ValueAnimator colorAnimation = ValueAnimator.ofObject(new ArgbEvaluator(), 403 mCurrentBackgroundColor, newColor); 404 colorAnimation.setDuration(BACKGROUND_CHANGE_ANIM_TIME_MS); 405 colorAnimation.addUpdateListener(mBackgroundColorUpdater); 406 colorAnimation.start(); 407 408 mCurrentBackgroundColor = newColor; 409 } 410 411 private void setBackgroundColor(int backgroundColor) { 412 mRadioBackground.setBackgroundColor(backgroundColor); 413 414 if (mRadioPresetBackground != null) { 415 mRadioPresetBackground.setBackgroundColor(backgroundColor); 416 } 417 418 if (mShouldColorStatusBar) { 419 int red = darkenColor(Color.red(backgroundColor)); 420 int green = darkenColor(Color.green(backgroundColor)); 421 int blue = darkenColor(Color.blue(backgroundColor)); 422 int alpha = Color.alpha(backgroundColor); 423 424 mActivity.getWindow().setStatusBarColor( 425 Color.argb(alpha, red, green, blue)); 426 } 427 } 428 429 /** 430 * Darkens the given color by {@link #STATUS_BAR_DARKEN_PERCENTAGE}. 431 */ 432 private int darkenColor(int color) { 433 return (int) Math.max(color - (color * STATUS_BAR_DARKEN_PERCENTAGE), 0); 434 } 435 436 /** 437 * Clears all metadata including song title, artist and station information. 438 */ 439 private void clearMetadataDisplay() { 440 mRadioDisplayController.setCurrentStation(null); 441 mRadioDisplayController.setCurrentSongTitleAndArtist(null, null); 442 } 443 444 /** 445 * Closes any active {@link RadioTuner}s and releases audio focus. 446 */ 447 private void close() { 448 if (Log.isLoggable(TAG, Log.DEBUG)) { 449 Log.d(TAG, "close()"); 450 } 451 452 // Lost focus, so display that the radio is not playing anymore. 453 mRadioDisplayController.setPlayPauseButtonState(true); 454 } 455 456 /** 457 * Closes all active connections in the {@link RadioController}. 458 */ 459 public void shutdown() { 460 if (Log.isLoggable(TAG, Log.DEBUG)) { 461 Log.d(TAG, "shutdown()"); 462 } 463 464 mActivity.unbindService(mServiceConnection); 465 mRadioStorage.removePresetsChangeListener(this); 466 467 if (mRadioManager != null) { 468 try { 469 mRadioManager.removeRadioTunerCallback(mCallback); 470 } catch (RemoteException e) { 471 Log.e(TAG, "tuneToRadioChannel(); remote exception: " + e.getMessage()); 472 } 473 } 474 475 close(); 476 } 477 478 @Override 479 public void onPresetsRefreshed() { 480 // Check if the current channel's preset status has changed. 481 ProgramInfo info = mCurrentProgram; 482 boolean isPreset = (info != null) && mRadioStorage.isPreset(info.getSelector()); 483 mRadioDisplayController.setChannelIsPreset(isPreset); 484 } 485 486 /** 487 * Gets a list of programs from the radio tuner's background scan 488 */ 489 public List<ProgramInfo> getProgramList() { 490 if (mRadioManager != null) { 491 try { 492 return mRadioManager.getProgramList(); 493 } catch (RemoteException e) { 494 Log.e(TAG, "getProgramList(); remote exception: " + e.getMessage()); 495 } 496 } 497 return null; 498 } 499 500 private final IRadioCallback.Stub mCallback = new IRadioCallback.Stub() { 501 @Override 502 public void onCurrentProgramInfoChanged(ProgramInfo info) { 503 mCurrentProgram = Objects.requireNonNull(info); 504 ProgramSelector sel = info.getSelector(); 505 506 updateRadioChannelDisplay(sel); 507 508 mRadioDisplayController.setCurrentStation( 509 ProgramInfoExt.getProgramName(info, ProgramInfoExt.NAME_NO_CHANNEL_FALLBACK)); 510 RadioMetadata meta = ProgramInfoExt.getMetadata(mCurrentProgram); 511 mRadioDisplayController.setCurrentSongTitleAndArtist( 512 meta.getString(RadioMetadata.METADATA_KEY_TITLE), 513 meta.getString(RadioMetadata.METADATA_KEY_ARTIST)); 514 515 mRadioDisplayController.setChannelIsPreset(mRadioStorage.isPreset(sel)); 516 517 // Notify that the current radio station has changed. 518 if (mProgramInfoChangeListeners != null) { 519 for (ProgramInfoChangeListener listener : mProgramInfoChangeListeners) { 520 listener.onProgramInfoChanged(info); 521 } 522 } 523 } 524 525 @Override 526 public void onRadioMuteChanged(boolean isMuted) { 527 mRadioDisplayController.setPlayPauseButtonState(isMuted); 528 } 529 530 @Override 531 public void onError(int status) { 532 Log.e(TAG, "Radio callback error with status: " + status); 533 close(); 534 } 535 }; 536 537 private final View.OnClickListener mBackwardSeekClickListener = new View.OnClickListener() { 538 @Override 539 public void onClick(View v) { 540 if (mRadioManager == null) return; 541 542 // TODO(b/73950974): show some kind of animation 543 clearMetadataDisplay(); 544 545 try { 546 // TODO(b/73950974): watch for timeout and if it happens, display metadata back 547 mRadioManager.seekBackward(); 548 } catch (RemoteException e) { 549 Log.e(TAG, "backwardSeek(); remote exception: " + e.getMessage()); 550 } 551 } 552 }; 553 554 private final View.OnClickListener mForwardSeekClickListener = new View.OnClickListener() { 555 @Override 556 public void onClick(View v) { 557 if (mRadioManager == null) return; 558 559 clearMetadataDisplay(); 560 561 try { 562 mRadioManager.seekForward(); 563 } catch (RemoteException e) { 564 Log.e(TAG, "Couldn't seek forward", e); 565 } 566 } 567 }; 568 569 /** 570 * Click listener for the play/pause button. Currently, all this does is mute/unmute the radio 571 * because the {@link RadioManager} does not support the ability to pause/start again. 572 */ 573 private final View.OnClickListener mPlayPauseClickListener = new View.OnClickListener() { 574 @Override 575 public void onClick(View v) { 576 if (mRadioManager == null) { 577 return; 578 } 579 580 try { 581 if (Log.isLoggable(TAG, Log.DEBUG)) { 582 Log.d(TAG, "Play button clicked. Currently muted: " + mRadioManager.isMuted()); 583 } 584 585 if (mRadioManager.isMuted()) { 586 mRadioManager.unMute(); 587 } else { 588 mRadioManager.mute(); 589 } 590 591 boolean isMuted = mRadioManager.isMuted(); 592 593 mUserHasMuted = isMuted; 594 mRadioDisplayController.setPlayPauseButtonState(isMuted); 595 } catch (RemoteException e) { 596 Log.e(TAG, "playPauseClickListener(); remote exception: " + e.getMessage()); 597 } 598 } 599 }; 600 601 private final View.OnClickListener mPresetButtonClickListener = new View.OnClickListener() { 602 // TODO: Maybe add a check to send a store/remove preset event after a delay so that 603 // there aren't multiple writes if the user presses the button quickly. 604 @Override 605 public void onClick(View v) { 606 ProgramInfo info = mCurrentProgram; 607 if (info == null) return; 608 609 ProgramSelector sel = mCurrentProgram.getSelector(); 610 boolean isPreset = mRadioStorage.isPreset(sel); 611 612 if (isPreset) { 613 mRadioStorage.removePreset(sel); 614 } else { 615 mRadioStorage.storePreset(Program.fromProgramInfo(info)); 616 } 617 618 // Update the UI immediately. If the preset failed for some reason, the RadioStorage 619 // will notify us and UI update will happen then. 620 mRadioDisplayController.setChannelIsPreset(!isPreset); 621 } 622 }; 623 624 private ServiceConnection mServiceConnection = new ServiceConnection() { 625 @Override 626 public void onServiceConnected(ComponentName className, IBinder binder) { 627 mRadioManager = ((IRadioManager) binder); 628 629 try { 630 if (mRadioManager == null || !mRadioManager.isInitialized()) { 631 mRadioDisplayController.setEnabled(false); 632 633 if (mRadioErrorDisplay != null) { 634 mRadioErrorDisplay.setVisibility(View.VISIBLE); 635 } 636 637 return; 638 } 639 640 mRadioDisplayController.setEnabled(true); 641 642 if (mRadioErrorDisplay != null) { 643 mRadioErrorDisplay.setVisibility(View.GONE); 644 } 645 646 mRadioDisplayController.setSingleChannelDisplay(mRadioBackground); 647 648 mRadioManager.addRadioTunerCallback(mCallback); 649 650 // Notify listeners 651 for (RadioServiceConnectionListener listener : mRadioServiceConnectionListeners) { 652 listener.onRadioServiceConnected(); 653 } 654 } catch (RemoteException e) { 655 Log.e(TAG, "onServiceConnected(); remote exception: " + e.getMessage()); 656 } 657 } 658 659 @Override 660 public void onServiceDisconnected(ComponentName className) { 661 mRadioManager = null; 662 } 663 }; 664 665 private final ValueAnimator.AnimatorUpdateListener mBackgroundColorUpdater = 666 animator -> { 667 int backgroundColor = (int) animator.getAnimatedValue(); 668 setBackgroundColor(backgroundColor); 669 }; 670 } 671