| /* |
| * Copyright (C) 2016 The Android Open Source Project |
| * |
| * 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 com.android.car.radio; |
| |
| import android.animation.ArgbEvaluator; |
| import android.animation.ValueAnimator; |
| import android.annotation.ColorInt; |
| import android.annotation.NonNull; |
| import android.annotation.Nullable; |
| import android.app.Activity; |
| import android.content.ComponentName; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.content.ServiceConnection; |
| import android.graphics.Color; |
| import android.hardware.radio.ProgramSelector; |
| import android.hardware.radio.RadioManager; |
| import android.hardware.radio.RadioManager.ProgramInfo; |
| import android.hardware.radio.RadioMetadata; |
| import android.hardware.radio.RadioTuner; |
| import android.os.IBinder; |
| import android.os.RemoteException; |
| import android.util.Log; |
| import android.view.View; |
| |
| import com.android.car.broadcastradio.support.Program; |
| import com.android.car.broadcastradio.support.platform.ProgramInfoExt; |
| import com.android.car.broadcastradio.support.platform.ProgramSelectorExt; |
| import com.android.car.radio.service.IRadioCallback; |
| import com.android.car.radio.service.IRadioManager; |
| import com.android.car.radio.storage.RadioStorage; |
| import com.android.car.radio.utils.ProgramSelectorUtils; |
| |
| import java.util.ArrayList; |
| import java.util.List; |
| import java.util.Objects; |
| |
| /** |
| * A controller that handles the display of metadata on the current radio station. |
| */ |
| public class RadioController implements RadioStorage.PresetsChangeListener { |
| private static final String TAG = "Em.RadioController"; |
| |
| /** |
| * The percentage by which to darken the color that should be set on the status bar. |
| * This darkening gives the status bar the illusion that it is transparent. |
| * |
| * @see RadioController#setShouldColorStatusBar(boolean) |
| */ |
| private static final float STATUS_BAR_DARKEN_PERCENTAGE = 0.4f; |
| |
| /** |
| * The animation time for when the background of the radio shifts to a different color. |
| */ |
| private static final int BACKGROUND_CHANGE_ANIM_TIME_MS = 450; |
| private static final int INVALID_BACKGROUND_COLOR = 0; |
| |
| private static final int CHANNEL_CHANGE_DURATION_MS = 200; |
| |
| private final ValueAnimator mAnimator = new ValueAnimator(); |
| private int mCurrentlyDisplayedChannel; // for animation purposes |
| private ProgramInfo mCurrentProgram; |
| |
| private final Activity mActivity; |
| private IRadioManager mRadioManager; |
| |
| private View mRadioBackground; |
| private boolean mShouldColorStatusBar; |
| private boolean mShouldColorBackground; |
| |
| /** |
| * An additional layer on top of the background that should match the color of |
| * {@link #mRadioBackground}. This view should only exist in the preset list. The reason this |
| * layer cannot be transparent is because it needs to be elevated, and elevation does not |
| * work if the background is undefined or transparent. |
| */ |
| private View mRadioPresetBackground; |
| |
| private View mRadioErrorDisplay; |
| |
| private final RadioChannelColorMapper mColorMapper; |
| @ColorInt private int mCurrentBackgroundColor = INVALID_BACKGROUND_COLOR; |
| |
| private final RadioDisplayController mRadioDisplayController; |
| |
| private final RadioStorage mRadioStorage; |
| |
| private final String mAmBandString; |
| private final String mFmBandString; |
| |
| private final List<ProgramInfoChangeListener> mProgramInfoChangeListeners = new ArrayList<>(); |
| private final List<RadioServiceConnectionListener> mRadioServiceConnectionListeners = |
| new ArrayList<>(); |
| |
| /** |
| * Interface for a class that will be notified when the current radio station has been changed. |
| */ |
| public interface ProgramInfoChangeListener { |
| /** |
| * Called when the current radio station has changed in the radio. |
| * |
| * @param info The current radio station. |
| */ |
| void onProgramInfoChanged(@NonNull ProgramInfo info); |
| } |
| |
| /** |
| * Interface for a class that will be notified when RadioService is successfuly bound |
| */ |
| public interface RadioServiceConnectionListener { |
| |
| /** |
| * Called when the RadioService is successfully connected |
| */ |
| void onRadioServiceConnected(); |
| } |
| |
| public RadioController(Activity activity) { |
| mActivity = activity; |
| |
| mRadioDisplayController = new RadioDisplayController(mActivity); |
| mColorMapper = RadioChannelColorMapper.getInstance(mActivity); |
| |
| mAmBandString = mActivity.getString(R.string.radio_am_text); |
| mFmBandString = mActivity.getString(R.string.radio_fm_text); |
| |
| mRadioStorage = RadioStorage.getInstance(mActivity); |
| mRadioStorage.addPresetsChangeListener(this); |
| mShouldColorBackground = true; |
| } |
| |
| /** |
| * Initializes this {@link RadioController} to control the UI whose root is the given container. |
| */ |
| public void initialize(View container) { |
| mCurrentBackgroundColor = INVALID_BACKGROUND_COLOR; |
| |
| mRadioDisplayController.initialize(container); |
| |
| mRadioDisplayController.setBackwardSeekButtonListener(mBackwardSeekClickListener); |
| mRadioDisplayController.setForwardSeekButtonListener(mForwardSeekClickListener); |
| mRadioDisplayController.setPlayButtonListener(mPlayPauseClickListener); |
| mRadioDisplayController.setAddPresetButtonListener(mPresetButtonClickListener); |
| |
| mRadioBackground = container; |
| mRadioPresetBackground = container.findViewById(R.id.preset_current_card_container); |
| |
| mRadioErrorDisplay = container.findViewById(R.id.radio_error_display); |
| |
| updateRadioDisplay(); |
| } |
| |
| /** |
| * Set whether or not this controller should also update the color of the status bar to match |
| * the current background color of the radio. The color that will be set on the status bar |
| * will be slightly darker, giving the illusion that the status bar is transparent. |
| * |
| * <p>This method is needed because of scene transitions. Scene transitions do not take into |
| * account padding that is added programmatically. Since there is no way to get the height of |
| * the status bar and set it in XML, it needs to be done in code. This breaks the scene |
| * transition. |
| * |
| * <p>To make this work, the status bar is not actually translucent; it is colored to appear |
| * that way via this method. |
| */ |
| public void setShouldColorStatusBar(boolean shouldColorStatusBar) { |
| mShouldColorStatusBar = shouldColorStatusBar; |
| } |
| |
| /** |
| * Set whether this controller should update the background color. |
| * This behavior is enabled by defaullt |
| */ |
| public void setShouldColorBackground(boolean shouldColorBackground) { |
| mShouldColorBackground = shouldColorBackground; |
| } |
| |
| /** |
| * Adds a listener that will be notified whenever the radio station changes. |
| */ |
| public void addProgramInfoChangeListener(ProgramInfoChangeListener listener) { |
| mProgramInfoChangeListeners.add(listener); |
| } |
| |
| /** |
| * Removes a listener that will be notified whenever the radio station changes. |
| */ |
| public void removeProgramInfoChangeListener(ProgramInfoChangeListener listener) { |
| mProgramInfoChangeListeners.remove(listener); |
| } |
| |
| /** |
| * Sets the listeners that will be notified when the radio service is connected. |
| */ |
| public void addRadioServiceConnectionListener(RadioServiceConnectionListener listener) { |
| mRadioServiceConnectionListeners.add(listener); |
| } |
| |
| /** |
| * Removes a listener that will be notified when the radio service is connected. |
| */ |
| public void removeRadioServiceConnectionListener(RadioServiceConnectionListener listener) { |
| mRadioServiceConnectionListeners.remove(listener); |
| } |
| |
| /** |
| * Starts the controller to handle radio tuning. This method should be called to begin |
| * radio playback. |
| */ |
| public void start() { |
| if (Log.isLoggable(TAG, Log.DEBUG)) { |
| Log.d(TAG, "starting radio"); |
| } |
| |
| Intent bindIntent = new Intent(RadioService.ACTION_UI_SERVICE, null /* uri */, |
| mActivity, RadioService.class); |
| if (!mActivity.bindService(bindIntent, mServiceConnection, Context.BIND_AUTO_CREATE)) { |
| Log.e(TAG, "Failed to connect to RadioService."); |
| } |
| |
| updateRadioDisplay(); |
| } |
| |
| /** |
| * Retrieves information about the current radio station from {@link #mRadioManager} and updates |
| * the display of that information accordingly. |
| */ |
| private void updateRadioDisplay() { |
| if (mRadioManager == null) { |
| return; |
| } |
| |
| try { |
| mRadioDisplayController.setSingleChannelDisplay(mRadioBackground); |
| |
| // TODO(b/73950974): use callback only |
| ProgramInfo current = mRadioManager.getCurrentProgramInfo(); |
| if (current != null) mCallback.onCurrentProgramInfoChanged(current); |
| } catch (RemoteException e) { |
| Log.e(TAG, "updateRadioDisplay(); remote exception: " + e.getMessage()); |
| } |
| } |
| |
| /** |
| * Tunes the radio to the given channel if it is valid and a {@link RadioTuner} has been opened. |
| */ |
| public void tune(ProgramSelector sel) { |
| if (mRadioManager == null) return; |
| |
| try { |
| mRadioManager.tune(sel); |
| } catch (RemoteException ex) { |
| Log.e(TAG, "Failed to tune", ex); |
| } |
| } |
| |
| /** |
| * Returns the band this radio is currently tuned to. |
| * |
| * TODO(b/73950974): don't be AM/FM exclusive |
| */ |
| public int getCurrentRadioBand() { |
| return ProgramSelectorUtils.getRadioBand(mCurrentProgram.getSelector()); |
| } |
| |
| /** |
| * Returns the radio station that is currently playing on the radio. If this controller is |
| * not connected to the {@link RadioService} or a radio station cannot be retrieved, then |
| * {@code null} is returned. |
| * |
| * TODO(b/73950974): use callback only |
| */ |
| @Nullable |
| public ProgramInfo getCurrentProgramInfo() { |
| return mCurrentProgram; |
| } |
| |
| /** |
| * Switch radio band. Currently, this only supports FM and AM bands. |
| * |
| * @param radioBand One of {@link RadioManager#BAND_FM}, {@link RadioManager#BAND_AM}. |
| */ |
| public void switchBand(int radioBand) { |
| try { |
| mRadioManager.switchBand(radioBand); |
| } catch (RemoteException e) { |
| Log.e(TAG, "Couldn't switch band", e); |
| } |
| } |
| |
| /** |
| * Delegates to the {@link RadioDisplayController} to highlight the radio band. |
| */ |
| private void updateAmFmDisplayState(int band) { |
| switch (band) { |
| case RadioManager.BAND_FM: |
| mRadioDisplayController.setChannelBand(mFmBandString); |
| break; |
| |
| case RadioManager.BAND_AM: |
| mRadioDisplayController.setChannelBand(mAmBandString); |
| break; |
| |
| // TODO: Support BAND_FM_HD and BAND_AM_HD. |
| |
| default: |
| mRadioDisplayController.setChannelBand(null); |
| } |
| } |
| |
| // TODO(b/73950974): move channel animation to RadioDisplayController |
| private void updateRadioChannelDisplay(@NonNull ProgramSelector sel) { |
| int priType = sel.getPrimaryId().getType(); |
| |
| mAnimator.cancel(); |
| |
| if (!ProgramSelectorExt.isAmFmProgram(sel) |
| || !ProgramSelectorExt.hasId(sel, ProgramSelector.IDENTIFIER_TYPE_AMFM_FREQUENCY)) { |
| // channel animation is implemented for AM/FM only |
| mCurrentlyDisplayedChannel = 0; |
| mRadioDisplayController.setChannelNumber(""); |
| |
| updateAmFmDisplayState(RadioStorage.INVALID_RADIO_BAND); |
| return; |
| } |
| |
| int freq = (int)sel.getFirstId(ProgramSelector.IDENTIFIER_TYPE_AMFM_FREQUENCY); |
| |
| boolean wasAm = ProgramSelectorExt.isAmFrequency(mCurrentlyDisplayedChannel); |
| boolean wasFm = ProgramSelectorExt.isFmFrequency(mCurrentlyDisplayedChannel); |
| boolean isAm = ProgramSelectorExt.isAmFrequency(freq); |
| int band = isAm ? RadioManager.BAND_AM : RadioManager.BAND_FM; |
| |
| updateAmFmDisplayState(band); |
| |
| if (isAm && wasAm || !isAm && wasFm) { |
| mAnimator.setIntValues((int)mCurrentlyDisplayedChannel, (int)freq); |
| mAnimator.setDuration(CHANNEL_CHANGE_DURATION_MS); |
| mAnimator.addUpdateListener(animation -> mRadioDisplayController.setChannelNumber( |
| ProgramSelectorExt.formatAmFmFrequency((int)animation.getAnimatedValue(), |
| ProgramSelectorExt.NAME_NO_MODULATION))); |
| mAnimator.start(); |
| } else { |
| // it's a different band - don't animate |
| mRadioDisplayController.setChannelNumber( |
| ProgramSelectorExt.getDisplayName(sel, ProgramSelectorExt.NAME_NO_MODULATION)); |
| } |
| mCurrentlyDisplayedChannel = freq; |
| |
| maybeUpdateBackgroundColor(freq); |
| } |
| |
| /** |
| * Checks if the color of the radio background should be changed, and if so, animates that |
| * color change. |
| */ |
| private void maybeUpdateBackgroundColor(int channel) { |
| if (mRadioBackground == null || !mShouldColorBackground) { |
| return; |
| } |
| |
| int newColor = mColorMapper.getColorForChannel(channel); |
| |
| // No animation required if the colors are the same. |
| if (newColor == mCurrentBackgroundColor) { |
| return; |
| } |
| |
| // If the current background color is invalid, then just set as the new color without any |
| // animation. |
| if (mCurrentBackgroundColor == INVALID_BACKGROUND_COLOR) { |
| mCurrentBackgroundColor = newColor; |
| setBackgroundColor(newColor); |
| } |
| |
| // Otherwise, animate the background color change. |
| ValueAnimator colorAnimation = ValueAnimator.ofObject(new ArgbEvaluator(), |
| mCurrentBackgroundColor, newColor); |
| colorAnimation.setDuration(BACKGROUND_CHANGE_ANIM_TIME_MS); |
| colorAnimation.addUpdateListener(mBackgroundColorUpdater); |
| colorAnimation.start(); |
| |
| mCurrentBackgroundColor = newColor; |
| } |
| |
| private void setBackgroundColor(int backgroundColor) { |
| mRadioBackground.setBackgroundColor(backgroundColor); |
| |
| if (mRadioPresetBackground != null) { |
| mRadioPresetBackground.setBackgroundColor(backgroundColor); |
| } |
| |
| if (mShouldColorStatusBar) { |
| int red = darkenColor(Color.red(backgroundColor)); |
| int green = darkenColor(Color.green(backgroundColor)); |
| int blue = darkenColor(Color.blue(backgroundColor)); |
| int alpha = Color.alpha(backgroundColor); |
| |
| mActivity.getWindow().setStatusBarColor( |
| Color.argb(alpha, red, green, blue)); |
| } |
| } |
| |
| /** |
| * Darkens the given color by {@link #STATUS_BAR_DARKEN_PERCENTAGE}. |
| */ |
| private int darkenColor(int color) { |
| return (int) Math.max(color - (color * STATUS_BAR_DARKEN_PERCENTAGE), 0); |
| } |
| |
| /** |
| * Clears all metadata including song title, artist and station information. |
| */ |
| private void clearMetadataDisplay() { |
| mRadioDisplayController.setCurrentStation(null); |
| mRadioDisplayController.setCurrentSongTitleAndArtist(null, null); |
| } |
| |
| /** |
| * Closes all active connections in the {@link RadioController}. |
| */ |
| public void shutdown() { |
| if (Log.isLoggable(TAG, Log.DEBUG)) { |
| Log.d(TAG, "shutdown()"); |
| } |
| |
| mActivity.unbindService(mServiceConnection); |
| mRadioStorage.removePresetsChangeListener(this); |
| |
| if (mRadioManager != null) { |
| try { |
| mRadioManager.removeRadioTunerCallback(mCallback); |
| } catch (RemoteException e) { |
| Log.e(TAG, "tuneToRadioChannel(); remote exception: " + e.getMessage()); |
| } |
| } |
| } |
| |
| @Override |
| public void onPresetsRefreshed() { |
| // Check if the current channel's preset status has changed. |
| ProgramInfo info = mCurrentProgram; |
| boolean isPreset = (info != null) && mRadioStorage.isPreset(info.getSelector()); |
| mRadioDisplayController.setChannelIsPreset(isPreset); |
| } |
| |
| /** |
| * Gets a list of programs from the radio tuner's background scan |
| */ |
| public List<ProgramInfo> getProgramList() { |
| if (mRadioManager != null) { |
| try { |
| return mRadioManager.getProgramList(); |
| } catch (RemoteException e) { |
| Log.e(TAG, "getProgramList(); remote exception: " + e.getMessage()); |
| } |
| } |
| return null; |
| } |
| |
| private final IRadioCallback.Stub mCallback = new IRadioCallback.Stub() { |
| @Override |
| public void onCurrentProgramInfoChanged(ProgramInfo info) { |
| mCurrentProgram = Objects.requireNonNull(info); |
| ProgramSelector sel = info.getSelector(); |
| |
| updateRadioChannelDisplay(sel); |
| |
| mRadioDisplayController.setCurrentStation( |
| ProgramInfoExt.getProgramName(info, ProgramInfoExt.NAME_NO_CHANNEL_FALLBACK)); |
| RadioMetadata meta = ProgramInfoExt.getMetadata(mCurrentProgram); |
| mRadioDisplayController.setCurrentSongTitleAndArtist( |
| meta.getString(RadioMetadata.METADATA_KEY_TITLE), |
| meta.getString(RadioMetadata.METADATA_KEY_ARTIST)); |
| |
| mRadioDisplayController.setChannelIsPreset(mRadioStorage.isPreset(sel)); |
| |
| // Notify that the current radio station has changed. |
| for (ProgramInfoChangeListener listener : mProgramInfoChangeListeners) { |
| listener.onProgramInfoChanged(info); |
| } |
| } |
| }; |
| |
| private final View.OnClickListener mBackwardSeekClickListener = new View.OnClickListener() { |
| @Override |
| public void onClick(View v) { |
| if (mRadioManager == null) return; |
| |
| // TODO(b/73950974): show some kind of animation |
| clearMetadataDisplay(); |
| |
| try { |
| // TODO(b/73950974): watch for timeout and if it happens, display metadata back |
| mRadioManager.seekBackward(); |
| } catch (RemoteException e) { |
| Log.e(TAG, "backwardSeek(); remote exception: " + e.getMessage()); |
| } |
| } |
| }; |
| |
| private final View.OnClickListener mForwardSeekClickListener = new View.OnClickListener() { |
| @Override |
| public void onClick(View v) { |
| if (mRadioManager == null) return; |
| |
| clearMetadataDisplay(); |
| |
| try { |
| mRadioManager.seekForward(); |
| } catch (RemoteException e) { |
| Log.e(TAG, "Couldn't seek forward", e); |
| } |
| } |
| }; |
| |
| /** |
| * Click listener for the play/pause button. Currently, all this does is mute/unmute the radio |
| * because the {@link RadioManager} does not support the ability to pause/start again. |
| */ |
| private final View.OnClickListener mPlayPauseClickListener = new View.OnClickListener() { |
| @Override |
| public void onClick(View v) { |
| if (mRadioManager == null) { |
| return; |
| } |
| |
| try { |
| if (Log.isLoggable(TAG, Log.DEBUG)) { |
| Log.d(TAG, "Play button clicked. Currently muted: " + mRadioManager.isMuted()); |
| } |
| |
| if (mRadioManager.isMuted()) { |
| mRadioManager.unMute(); |
| } else { |
| mRadioManager.mute(); |
| } |
| } catch (RemoteException e) { |
| Log.e(TAG, "playPauseClickListener(); remote exception: " + e.getMessage()); |
| } |
| } |
| }; |
| |
| private final View.OnClickListener mPresetButtonClickListener = new View.OnClickListener() { |
| // TODO: Maybe add a check to send a store/remove preset event after a delay so that |
| // there aren't multiple writes if the user presses the button quickly. |
| @Override |
| public void onClick(View v) { |
| ProgramInfo info = mCurrentProgram; |
| if (info == null) return; |
| |
| ProgramSelector sel = mCurrentProgram.getSelector(); |
| boolean isPreset = mRadioStorage.isPreset(sel); |
| |
| if (isPreset) { |
| mRadioStorage.removePreset(sel); |
| } else { |
| mRadioStorage.storePreset(Program.fromProgramInfo(info)); |
| } |
| |
| // Update the UI immediately. If the preset failed for some reason, the RadioStorage |
| // will notify us and UI update will happen then. |
| mRadioDisplayController.setChannelIsPreset(!isPreset); |
| } |
| }; |
| |
| private ServiceConnection mServiceConnection = new ServiceConnection() { |
| @Override |
| public void onServiceConnected(ComponentName className, IBinder binder) { |
| mRadioManager = ((IRadioManager) binder); |
| |
| try { |
| if (mRadioManager == null || !mRadioManager.isInitialized()) { |
| mRadioDisplayController.setEnabled(false); |
| |
| if (mRadioErrorDisplay != null) { |
| mRadioErrorDisplay.setVisibility(View.VISIBLE); |
| } |
| |
| return; |
| } |
| |
| mRadioDisplayController.setEnabled(true); |
| mRadioManager.addPlaybackStateListener(mRadioDisplayController); |
| |
| if (mRadioErrorDisplay != null) { |
| mRadioErrorDisplay.setVisibility(View.GONE); |
| } |
| |
| mRadioDisplayController.setSingleChannelDisplay(mRadioBackground); |
| |
| mRadioManager.addRadioTunerCallback(mCallback); |
| |
| // Notify listeners |
| for (RadioServiceConnectionListener listener : mRadioServiceConnectionListeners) { |
| listener.onRadioServiceConnected(); |
| } |
| } catch (RemoteException e) { |
| Log.e(TAG, "onServiceConnected(); remote exception: " + e.getMessage()); |
| } |
| } |
| |
| @Override |
| public void onServiceDisconnected(ComponentName className) { |
| mRadioManager = null; |
| } |
| }; |
| |
| private final ValueAnimator.AnimatorUpdateListener mBackgroundColorUpdater = |
| animator -> { |
| int backgroundColor = (int) animator.getAnimatedValue(); |
| setBackgroundColor(backgroundColor); |
| }; |
| } |