rows) {
+ this.activity = context;
+ mRows = rows;
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ View row = getActivity().getLayoutInflater().inflate(rowLayout, parent, false);
+ TextView text1 = (TextView) row.findViewById(textId);
+ ImageView icon = (ImageView) row.findViewById(iconId);
+
+ text1.setText(mRows.get(position).getText());
+ icon.setImageResource(mRows.get(position).getIcon());
+
+ return row;
+ }
+
+ public Activity getActivity() {
+ return activity;
+ }
+
+ /**
+ * Helper class to represent a row. Each row has an identifier, a string, and an icon.
+ *
+ * The identifier should be unique across all rows in a given {@link IconRowAdapter}, and will
+ * be used as the id parameter to the OnItemClickListener.
+ */
+ public static class IconRow {
+
+ private long mId;
+
+ private CharSequence mText;
+
+ private int mIcon;
+
+ IconRow(long id, CharSequence text, int icon) {
+ mId = id;
+ mText = text;
+ mIcon = icon;
+ }
+
+ public long getId() {
+ return mId;
+ }
+
+ public void setId(long id) {
+ mId = id;
+ }
+
+ public CharSequence getText() {
+ return mText;
+ }
+
+ public void setText(String text) {
+ mText = text;
+ }
+
+ public int getIcon() {
+ return mIcon;
+ }
+
+ public void setIcon(int icon) {
+ mIcon = icon;
+ }
+ }
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/IntEditTextPreference.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/IntEditTextPreference.java
new file mode 100644
index 000000000..001f06ed3
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/IntEditTextPreference.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright (c) 2011 Kurt Aaholst
+ *
+ * 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 uk.org.ngo.squeezer;
+
+import android.content.Context;
+import android.util.AttributeSet;
+
+import androidx.preference.EditTextPreference;
+
+public class IntEditTextPreference extends EditTextPreference {
+
+ public IntEditTextPreference(Context context) {
+ super(context);
+ }
+
+ public IntEditTextPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public IntEditTextPreference(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ @Override
+ protected String getPersistedString(String defaultReturnValue) {
+ return String.valueOf(getPersistedInt(0));
+ }
+
+ @Override
+ protected boolean persistString(String value) {
+ return persistInt(Util.getInt(value));
+ }
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/NowPlayingActivity.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/NowPlayingActivity.java
new file mode 100644
index 000000000..0fc6eca49
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/NowPlayingActivity.java
@@ -0,0 +1,95 @@
+/*
+ * Copyright (c) 2009 Google Inc. 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 uk.org.ngo.squeezer;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.MenuItem;
+import android.view.MotionEvent;
+
+import androidx.appcompat.app.ActionBar;
+import androidx.core.view.GestureDetectorCompat;
+
+import uk.org.ngo.squeezer.framework.BaseActivity;
+import uk.org.ngo.squeezer.widget.OnSwipeListener;
+
+public class NowPlayingActivity extends BaseActivity {
+ private GestureDetectorCompat mDetector;
+
+ /**
+ * Called when the activity is first created.
+ */
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.now_playing);
+
+ ActionBar actionBar = getSupportActionBar();
+ if (actionBar != null) {
+ actionBar.setHomeAsUpIndicator(R.drawable.ic_action_down);
+ }
+
+ mDetector = new GestureDetectorCompat(this, new OnSwipeListener() {
+ @Override
+ public boolean onSwipeDown() {
+ finish();
+ return true;
+ }
+ });
+
+ ignoreIconMessages = true;
+ }
+
+ public static void show(Context context) {
+ final Intent intent = new Intent(context, NowPlayingActivity.class)
+ .addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT);
+
+ if (!(context instanceof Activity)) {
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ }
+ context.startActivity(intent);
+ if (context instanceof Activity) {
+ ((Activity) context).overridePendingTransition(R.anim.slide_in_up, android.R.anim.fade_out);
+ }
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ if (item.getItemId() == android.R.id.home) {
+ finish();
+ return true;
+ }
+
+ return super.onOptionsItemSelected(item);
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent event){
+ mDetector.onTouchEvent(event);
+ return super.onTouchEvent(event);
+ }
+
+ @Override
+ public void onPause() {
+ if (isFinishing()) {
+ overridePendingTransition(android.R.anim.fade_in, R.anim.slide_out_down);
+ }
+ super.onPause();
+ }
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/NowPlayingFragment.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/NowPlayingFragment.java
new file mode 100644
index 000000000..2783ae2e8
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/NowPlayingFragment.java
@@ -0,0 +1,1125 @@
+/*
+ * Copyright (c) 2012 Google Inc. 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 uk.org.ngo.squeezer;
+
+import android.app.Activity;
+import android.app.ProgressDialog;
+import android.content.BroadcastReceiver;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.ServiceConnection;
+import android.net.ConnectivityManager;
+import android.net.NetworkInfo;
+import android.net.wifi.WifiManager;
+import android.os.Bundle;
+import android.os.IBinder;
+import androidx.annotation.MainThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import androidx.core.view.GestureDetectorCompat;
+import androidx.fragment.app.DialogFragment;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+import androidx.fragment.app.FragmentTransaction;
+import androidx.appcompat.app.ActionBar;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AdapterView;
+import android.widget.ArrayAdapter;
+import android.widget.Button;
+import android.widget.ImageButton;
+import android.widget.ImageView;
+import android.widget.ProgressBar;
+import android.widget.SeekBar;
+import android.widget.SeekBar.OnSeekBarChangeListener;
+import android.widget.Spinner;
+import android.widget.TextView;
+
+import com.google.common.base.Joiner;
+import com.google.common.base.Strings;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+
+import uk.org.ngo.squeezer.dialog.AboutDialog;
+import uk.org.ngo.squeezer.dialog.EnableWifiDialog;
+import uk.org.ngo.squeezer.framework.BaseActivity;
+import uk.org.ngo.squeezer.framework.BaseItemView;
+import uk.org.ngo.squeezer.itemlist.AlarmsActivity;
+import uk.org.ngo.squeezer.itemlist.CurrentPlaylistActivity;
+import uk.org.ngo.squeezer.itemlist.HomeActivity;
+import uk.org.ngo.squeezer.itemlist.PlayerListActivity;
+import uk.org.ngo.squeezer.itemlist.JiveItemListActivity;
+import uk.org.ngo.squeezer.itemlist.JiveItemViewLogic;
+import uk.org.ngo.squeezer.itemlist.PlayerViewLogic;
+import uk.org.ngo.squeezer.model.CurrentPlaylistItem;
+import uk.org.ngo.squeezer.model.Player;
+import uk.org.ngo.squeezer.model.PlayerState;
+import uk.org.ngo.squeezer.model.PlayerState.RepeatStatus;
+import uk.org.ngo.squeezer.model.PlayerState.ShuffleStatus;
+import uk.org.ngo.squeezer.model.JiveItem;
+import uk.org.ngo.squeezer.service.ConnectionState;
+import uk.org.ngo.squeezer.service.ISqueezeService;
+import uk.org.ngo.squeezer.service.SqueezeService;
+import uk.org.ngo.squeezer.service.event.ConnectionChanged;
+import uk.org.ngo.squeezer.service.event.HandshakeComplete;
+import uk.org.ngo.squeezer.service.event.HomeMenuEvent;
+import uk.org.ngo.squeezer.service.event.MusicChanged;
+import uk.org.ngo.squeezer.service.event.PlayStatusChanged;
+import uk.org.ngo.squeezer.service.event.PlayersChanged;
+import uk.org.ngo.squeezer.service.event.PowerStatusChanged;
+import uk.org.ngo.squeezer.service.event.RegisterSqueezeNetwork;
+import uk.org.ngo.squeezer.service.event.RepeatStatusChanged;
+import uk.org.ngo.squeezer.service.event.ShuffleStatusChanged;
+import uk.org.ngo.squeezer.service.event.SongTimeChanged;
+import uk.org.ngo.squeezer.util.ImageFetcher;
+import uk.org.ngo.squeezer.widget.OnSwipeListener;
+
+public class NowPlayingFragment extends Fragment {
+
+ private static final String TAG = "NowPlayingFragment";
+
+ private BaseActivity mActivity;
+ private JiveItemViewLogic pluginViewDelegate;
+
+ @Nullable
+ private ISqueezeService mService = null;
+
+ private TextView albumText;
+
+ private TextView artistAlbumText;
+
+ private TextView artistText;
+
+ private TextView trackText;
+
+ @Nullable
+ private View btnContextMenu;
+
+ private TextView currentTime;
+
+ private TextView totalTime;
+
+ private MenuItem menu_item_disconnect;
+
+ private JiveItem globalSearch;
+ private MenuItem menu_item_search;
+
+ private MenuItem menu_item_players;
+
+ private MenuItem menu_item_toggle_power;
+ private MenuItem menu_item_sleep;
+ private MenuItem menu_item_sleep_at_end_of_song;
+ private MenuItem menu_item_cancel_sleep;
+
+ private MenuItem menu_item_alarm;
+
+ private ImageButton playPauseButton;
+
+ @Nullable
+ private ImageButton nextButton;
+
+ @Nullable
+ private ImageButton prevButton;
+
+ private ImageButton shuffleButton;
+
+ private ImageButton repeatButton;
+
+ private ImageView albumArt;
+
+ /** In full-screen mode, shows the current progress through the track. */
+ private SeekBar seekBar;
+
+ /** In mini-mode, shows the current progress through the track. */
+ private ProgressBar mProgressBar;
+
+ // Updating the seekbar
+ private boolean updateSeekBar = true;
+
+ private Button volumeButton;
+
+ private Button playlistButton;
+
+ private final BroadcastReceiver broadcastReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ ConnectivityManager connMgr = (ConnectivityManager) context
+ .getSystemService(Context.CONNECTIVITY_SERVICE);
+ NetworkInfo networkInfo = connMgr.getNetworkInfo(ConnectivityManager.TYPE_WIFI);
+ if (networkInfo.isConnected()) {
+ Log.v(TAG, "Received WIFI connected broadcast");
+ if (!isConnected()) {
+ // Requires a serviceStub. Else we'll do this on the service
+ // connection callback.
+ if (mService != null && !isManualDisconnect(context)) {
+ Log.v(TAG, "Initiated connect on WIFI connected");
+ startVisibleConnection();
+ }
+ }
+ }
+ }
+ };
+
+ /** Dialog displayed while connecting to the server. */
+ private ProgressDialog connectingDialog = null;
+
+ /**
+ * Shows the "connecting" dialog if it's not already showing.
+ */
+ @UiThread
+ private void showConnectingDialog() {
+ if (connectingDialog == null || !connectingDialog.isShowing()) {
+ Preferences preferences = new Preferences(mActivity);
+ Preferences.ServerAddress serverAddress = preferences.getServerAddress();
+
+ connectingDialog = ProgressDialog.show(mActivity,
+ getText(R.string.connecting_text),
+ getString(R.string.connecting_to_text, preferences.getServerName(serverAddress)),
+ true, false);
+ }
+ }
+
+ /**
+ * Dismisses the "connecting" dialog if it's showing.
+ */
+ @UiThread
+ private void dismissConnectingDialog() {
+ if (connectingDialog != null && connectingDialog.isShowing()) {
+ connectingDialog.dismiss();
+ }
+ connectingDialog = null;
+ }
+
+
+ private final ServiceConnection serviceConnection = new ServiceConnection() {
+ @Override
+ public void onServiceConnected(ComponentName name, IBinder binder) {
+ Log.v(TAG, "ServiceConnection.onServiceConnected()");
+ NowPlayingFragment.this.onServiceConnected((ISqueezeService) binder);
+ }
+
+ @Override
+ public void onServiceDisconnected(ComponentName name) {
+ mService = null;
+ }
+ };
+
+ private boolean mFullHeightLayout;
+
+ /**
+ * Called before onAttach. Pull out the layout spec to figure out which layout to use later.
+ */
+ @Override
+ public void onInflate(@NonNull Activity activity, @NonNull AttributeSet attrs, Bundle savedInstanceState) {
+ super.onInflate(activity, attrs, savedInstanceState);
+
+ int layout_height = attrs.getAttributeUnsignedIntValue(
+ "http://schemas.android.com/apk/res/android",
+ "layout_height", 0);
+
+ mFullHeightLayout = (layout_height == ViewGroup.LayoutParams.MATCH_PARENT);
+ }
+
+ @Override
+ public void onAttach(@NonNull Activity activity) {
+ super.onAttach(activity);
+ mActivity = (BaseActivity) activity;
+ pluginViewDelegate = new JiveItemViewLogic(mActivity);
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setHasOptionsMenu(true);
+
+ mActivity.bindService(new Intent(mActivity, SqueezeService.class), serviceConnection,
+ Context.BIND_AUTO_CREATE);
+ Log.d(TAG, "did bindService; serviceStub = " + mService);
+ }
+
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ View v;
+
+ if (mFullHeightLayout) {
+ v = inflater.inflate(R.layout.now_playing_fragment_full, container, false);
+
+ artistText = v.findViewById(R.id.artistname);
+ albumText = v.findViewById(R.id.albumname);
+ shuffleButton = v.findViewById(R.id.shuffle);
+ repeatButton = v.findViewById(R.id.repeat);
+ currentTime = v.findViewById(R.id.currenttime);
+ totalTime = v.findViewById(R.id.totaltime);
+ seekBar = v.findViewById(R.id.seekbar);
+ volumeButton = v.findViewById(R.id.volume);
+ playlistButton = v.findViewById(R.id.playlist);
+
+ final BaseItemView.ViewHolder viewHolder = new BaseItemView.ViewHolder(v);
+ viewHolder.contextMenuButton.setOnClickListener(view -> {
+ CurrentPlaylistItem currentSong = getCurrentSong();
+ // This extra check is if user pressed the button before visibility is set to GONE
+ if (currentSong != null) {
+ pluginViewDelegate.showContextMenu(viewHolder, currentSong);
+ }
+ });
+ btnContextMenu = viewHolder.contextMenuButtonHolder;
+ btnContextMenu.setTag(viewHolder);
+ } else {
+ v = inflater.inflate(R.layout.now_playing_fragment_mini, container, false);
+
+ mProgressBar = v.findViewById(R.id.progressbar);
+ artistAlbumText = v.findViewById(R.id.artistalbumname);
+ }
+
+ albumArt = v.findViewById(R.id.album);
+ trackText = v.findViewById(R.id.trackname);
+ playPauseButton = v.findViewById(R.id.pause);
+
+ // May or may not be present in the layout, depending on orientation,
+ // screen width, and so on.
+ nextButton = v.findViewById(R.id.next);
+ prevButton = v.findViewById(R.id.prev);
+
+ // Marquee effect on TextViews only works if they're focused.
+ trackText.requestFocus();
+
+ playPauseButton.setOnClickListener(view -> {
+ if (mService == null) {
+ return;
+ }
+ mService.togglePausePlay();
+ });
+
+ if (nextButton != null) {
+ nextButton.setOnClickListener(view -> {
+ if (mService == null) {
+ return;
+ }
+ mService.nextTrack();
+ });
+ }
+
+ if (prevButton != null) {
+ prevButton.setOnClickListener(view -> {
+ if (mService == null) {
+ return;
+ }
+ mService.previousTrack();
+ });
+ }
+
+ if (mFullHeightLayout) {
+ /*
+ * TODO: Simplify these following the notes at
+ * http://developer.android.com/resources/articles/ui-1.6.html.
+ * Maybe. because the TextView resources don't support the
+ * android:onClick attribute.
+ */
+ shuffleButton.setOnClickListener(view -> {
+ if (mService == null) {
+ return;
+ }
+ mService.toggleShuffle();
+ });
+
+ repeatButton.setOnClickListener(view -> {
+ if (mService == null) {
+ return;
+ }
+ mService.toggleRepeat();
+ });
+
+ volumeButton.setOnClickListener(view -> mActivity.showVolumePanel());
+
+ playlistButton.setOnClickListener(view -> CurrentPlaylistActivity.show(mActivity));
+
+ seekBar.setOnSeekBarChangeListener(new OnSeekBarChangeListener() {
+ CurrentPlaylistItem seekingSong;
+
+ // Update the time indicator to reflect the dragged thumb
+ // position.
+ @Override
+ public void onProgressChanged(SeekBar s, int progress, boolean fromUser) {
+ if (fromUser) {
+ currentTime.setText(Util.formatElapsedTime(progress));
+ }
+ }
+
+ // Disable updates when user drags the thumb.
+ @Override
+ public void onStartTrackingTouch(SeekBar s) {
+ seekingSong = getCurrentSong();
+ updateSeekBar = false;
+ }
+
+ // Re-enable updates. If the current song is the same as when
+ // we started seeking then jump to the new point in the track,
+ // otherwise ignore the seek.
+ @Override
+ public void onStopTrackingTouch(SeekBar s) {
+ CurrentPlaylistItem thisSong = getCurrentSong();
+
+ updateSeekBar = true;
+
+ if (seekingSong == thisSong) {
+ setSecondsElapsed(s.getProgress());
+ }
+ }
+ });
+ } else {
+ final GestureDetectorCompat detector = new GestureDetectorCompat(mActivity, new OnSwipeListener() {
+ // Clicking on the layout goes to NowPlayingActivity.
+ @Override
+ public boolean onSingleTapUp(MotionEvent e) {
+ NowPlayingActivity.show(mActivity);
+ return true;
+ }
+
+ // Swipe up on the layout goes to NowPlayingActivity.
+ @Override
+ public boolean onSwipeUp() {
+ NowPlayingActivity.show(mActivity);
+ return true;
+ }
+ });
+ v.setOnTouchListener((view, event) -> detector.onTouchEvent(event));
+ }
+
+ return v;
+ }
+
+ @UiThread
+ private void updatePlayPauseIcon(@PlayerState.PlayState String playStatus) {
+ playPauseButton
+ .setImageResource((PlayerState.PLAY_STATE_PLAY.equals(playStatus)) ?
+ R.drawable.ic_action_pause
+ : R.drawable.ic_action_play);
+ }
+
+ @UiThread
+ private void updateShuffleStatus(ShuffleStatus shuffleStatus) {
+ if (mFullHeightLayout && shuffleStatus != null) {
+ shuffleButton.setImageResource(shuffleStatus.getIcon());
+ }
+ }
+
+ @UiThread
+ private void updateRepeatStatus(RepeatStatus repeatStatus) {
+ if (mFullHeightLayout && repeatStatus != null) {
+ repeatButton.setImageResource(repeatStatus.getIcon());
+ }
+ }
+
+ @UiThread
+ private void updatePlayerMenuItems() {
+ // The fragment may no longer be attached to the parent activity. If so, do nothing.
+ if (!isAdded()) {
+ return;
+ }
+
+ Player player = getActivePlayer();
+ PlayerState playerState = player != null ? player.getPlayerState() : null;
+ String playerName = player != null ? player.getName() : "";
+
+ if (menu_item_toggle_power != null) {
+ if (playerState != null && player.isCanpoweroff()) {
+ menu_item_toggle_power.setTitle(getString(playerState.isPoweredOn() ? R.string.menu_item_poweroff : R.string.menu_item_poweron, playerName));
+ menu_item_toggle_power.setVisible(true);
+ } else {
+ menu_item_toggle_power.setVisible(false);
+ }
+ }
+
+ if (menu_item_cancel_sleep != null) {
+ menu_item_cancel_sleep.setVisible(playerState != null && playerState.getSleepDuration() != 0);
+ }
+
+ if (menu_item_sleep_at_end_of_song != null) {
+ menu_item_sleep_at_end_of_song.setVisible(playerState != null && playerState.isPlaying());
+ }
+ }
+
+ /**
+ * Manages the list of connected players in the action bar.
+ *
+ * @param players A list of players to show. May be empty (use {@code
+ * Collections.<Player>emptyList()}) but not null.
+ * @param activePlayer The currently active player. May be null.
+ */
+ @UiThread
+ private void updatePlayerDropDown(@NonNull Collection players,
+ @Nullable Player activePlayer) {
+ if (!isAdded()) {
+ return;
+ }
+
+ // Only include players that are connected to the server.
+ List connectedPlayers = new ArrayList<>();
+ for (Player player : players) {
+ if (player.getConnected()) {
+ connectedPlayers.add(player);
+ }
+ }
+ Collections.sort(connectedPlayers); // sort players alphabetically by player name
+
+ ActionBar actionBar = mActivity.getSupportActionBar();
+
+ // If there are multiple players connected then show a spinner allowing the user to
+ // choose between them.
+ if (connectedPlayers.size() > 1) {
+ actionBar.setDisplayShowTitleEnabled(false);
+ actionBar.setDisplayShowCustomEnabled(true);
+ actionBar.setCustomView(R.layout.action_bar_custom_view);
+ Spinner spinner = (Spinner) actionBar.getCustomView();
+ final Context actionBarContext = actionBar.getThemedContext();
+ final ArrayAdapter playerAdapter = new ArrayAdapter(
+ actionBarContext, android.R.layout.simple_spinner_dropdown_item,
+ connectedPlayers) {
+ @Override
+ public View getDropDownView(int position, View convertView, @NonNull ViewGroup parent) {
+ return Util.getActionBarSpinnerItemView(actionBarContext, convertView, parent,
+ getItem(position).getName());
+ }
+
+ @Override
+ public @NonNull View getView(int position, View convertView, @NonNull ViewGroup parent) {
+ return Util.getActionBarSpinnerItemView(actionBarContext, convertView, parent,
+ getItem(position).getName());
+ }
+ };
+ spinner.setAdapter(playerAdapter);
+ spinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
+ @Override
+ public void onItemSelected(AdapterView> parent, View view, int position, long id) {
+ if (!playerAdapter.getItem(position).equals(mService.getActivePlayer())) {
+ mService.setActivePlayer(playerAdapter.getItem(position));
+ updateUiFromPlayerState(mService.getActivePlayerState());
+ }
+ }
+
+ @Override
+ public void onNothingSelected(AdapterView> parent) {
+ }
+ });
+ if (activePlayer != null) {
+ spinner.setSelection(playerAdapter.getPosition(activePlayer));
+ }
+ } else {
+ // 0 or 1 players, disable the spinner, and either show the sole player in the
+ // action bar, or the app name if there are no players.
+ actionBar.setDisplayShowTitleEnabled(true);
+ actionBar.setDisplayShowCustomEnabled(false);
+
+ if (connectedPlayers.size() == 1) {
+ actionBar.setTitle(connectedPlayers.get(0).getName());
+ } else {
+ actionBar.setTitle(R.string.app_name);
+ }
+ }
+ }
+
+ protected void onServiceConnected(@NonNull ISqueezeService service) {
+ Log.v(TAG, "Service bound");
+ mService = service;
+
+ maybeRegisterCallbacks(mService);
+
+ // Assume they want to connect (unless manually disconnected).
+ if (!isConnected()) {
+ if (isManualDisconnect(mActivity)) {
+ ConnectActivity.show(mActivity);
+
+ } else {
+ startVisibleConnection();
+ }
+ }
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ Log.d(TAG, "onResume...");
+
+ if (mService != null) {
+ maybeRegisterCallbacks(mService);
+ }
+
+ if (new Preferences(mActivity).isAutoConnect()) {
+ mActivity.registerReceiver(broadcastReceiver, new IntentFilter(
+ ConnectivityManager.CONNECTIVITY_ACTION));
+ }
+ }
+
+ /**
+ * Keep track of whether callbacks have been registered
+ */
+ private boolean mRegisteredCallbacks;
+
+ /**
+ * This is called when the service is first connected, and whenever the activity is resumed.
+ */
+ private void maybeRegisterCallbacks(@NonNull ISqueezeService service) {
+ if (!mRegisteredCallbacks) {
+ service.getEventBus().registerSticky(this);
+
+ mRegisteredCallbacks = true;
+ }
+ }
+
+ @UiThread
+ private void updateTimeDisplayTo(int secondsIn, int secondsTotal) {
+ if (mFullHeightLayout) {
+ if (updateSeekBar) {
+ if (seekBar.getMax() != secondsTotal) {
+ seekBar.setMax(secondsTotal);
+ totalTime.setText(Util.formatElapsedTime(secondsTotal));
+ }
+ seekBar.setProgress(secondsIn);
+ currentTime.setText(Util.formatElapsedTime(secondsIn));
+ }
+ } else {
+ if (mProgressBar.getMax() != secondsTotal) {
+ mProgressBar.setMax(secondsTotal);
+ }
+ mProgressBar.setProgress(secondsIn);
+ }
+ }
+
+ /**
+ * Update the UI based on the player state. Call this when the active player
+ * changes.
+ *
+ * @param playerState the player state to reflect in the UI.
+ */
+ @UiThread
+ private void updateUiFromPlayerState(@NonNull PlayerState playerState) {
+ updateSongInfo(playerState);
+
+ updatePlayPauseIcon(playerState.getPlayStatus());
+ updateShuffleStatus(playerState.getShuffleStatus());
+ updateRepeatStatus(playerState.getRepeatStatus());
+ updatePlayerMenuItems();
+ }
+
+ /**
+ * Joins elements together with ' - ', skipping nulls.
+ */
+ protected static final Joiner mJoiner = Joiner.on(" - ").skipNulls();
+
+ /**
+ * Update the UI when the song changes, either because the track has changed, or the
+ * active player has changed.
+ *
+ * @param playerState the player state for the song.
+ */
+ @UiThread
+ private void updateSongInfo(@NonNull PlayerState playerState) {
+ updateTimeDisplayTo((int)playerState.getCurrentTimeSecond(),
+ playerState.getCurrentSongDuration());
+
+ CurrentPlaylistItem song = playerState.getCurrentSong();
+ if (song == null) {
+ // Create empty song if this is called (via _HandshakeComplete) before status is received
+ song = new CurrentPlaylistItem(new HashMap<>());
+ }
+
+ // TODO handle button remapping (buttons in status response)
+ if (!song.getTrack().isEmpty()) {
+ trackText.setText(song.getTrack());
+
+ // don't remove rew and fwd for remote tracks, because a single track playlist
+ // is not an indication that fwd and rwd are invalid actions
+ if ((playerState.getCurrentPlaylistTracksNum() == 1) && !playerState.isRemote()) {
+ disableButton(nextButton);
+ disableButton(prevButton);
+ if (btnContextMenu != null) {
+ btnContextMenu.setVisibility(View.GONE);
+ }
+ } else {
+ enableButton(nextButton);
+ enableButton(prevButton);
+ if (btnContextMenu != null) {
+ btnContextMenu.setVisibility(View.VISIBLE);
+ }
+ }
+
+ if (mFullHeightLayout) {
+ artistText.setText(song.getArtist());
+ albumText.setText(song.getAlbum());
+ totalTime.setText(Util.formatElapsedTime(playerState.getCurrentSongDuration()));
+ } else {
+ artistAlbumText.setText(mJoiner.join(
+ Strings.emptyToNull(song.getArtist()),
+ Strings.emptyToNull(song.getAlbum())));
+ }
+ } else {
+ trackText.setText("");
+ if (mFullHeightLayout) {
+ artistText.setText("");
+ albumText.setText("");
+ btnContextMenu.setVisibility(View.GONE);
+ } else {
+ artistAlbumText.setText("");
+ }
+ }
+
+ if (!song.hasArtwork()) {
+ albumArt.setImageDrawable(song.getIconDrawable(mActivity, R.drawable.icon_album_noart_fullscreen));
+ } else {
+ ImageFetcher.getInstance(mActivity).loadImage(song.getIcon(), albumArt);
+ }
+ }
+
+ /**
+ * Enable a button, which may be null.
+ *
+ * @param button the button to enable.
+ */
+ private static void enableButton(@Nullable ImageButton button) {
+ setButtonState(button, true);
+ }
+
+ /**
+ * Disable a button, which may be null.
+ *
+ * @param button the button to enable.
+ */
+ private static void disableButton(@Nullable ImageButton button) {
+ setButtonState(button, false);
+ }
+
+ /**
+ * Sets the state of a button to either enabled or disabled. Enabled buttons
+ * are active and have a 1.0 alpha, disabled buttons are inactive and have a
+ * 0.25 alpha. {@code button} may be null, in which case nothing happens.
+ *
+ * @param button the button to affect
+ * @param state the desired state, {@code true} to enable {@code false} to disable.
+ */
+ private static void setButtonState(@Nullable ImageButton button, boolean state) {
+ if (button == null) {
+ return;
+ }
+
+ button.setEnabled(state);
+ button.setAlpha(state ? 1.0f : 0.25f);
+ }
+
+ private boolean setSecondsElapsed(int seconds) {
+ return mService != null && mService.setSecondsElapsed(seconds);
+ }
+
+ private PlayerState getPlayerState() {
+ if (mService == null) {
+ return null;
+ }
+ return mService.getPlayerState();
+ }
+
+ private Player getActivePlayer() {
+ if (mService == null) {
+ return null;
+ }
+ return mService.getActivePlayer();
+ }
+
+ private CurrentPlaylistItem getCurrentSong() {
+ PlayerState playerState = getPlayerState();
+ return playerState != null ? playerState.getCurrentSong() : null;
+ }
+
+ private boolean isConnected() {
+ return mService != null && mService.isConnected();
+ }
+
+ private boolean isConnectInProgress() {
+ return mService != null && mService.isConnectInProgress();
+ }
+
+ @Override
+ public void onPause() {
+ Log.d(TAG, "onPause...");
+
+ dismissConnectingDialog();
+
+ if (new Preferences(mActivity).isAutoConnect()) {
+ mActivity.unregisterReceiver(broadcastReceiver);
+ }
+
+ if (mRegisteredCallbacks) {
+ mService.getEventBus().unregister(this);
+ mRegisteredCallbacks = false;
+ }
+
+ pluginViewDelegate.resetContextMenu();
+
+ super.onPause();
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ if (mService != null) {
+ mActivity.unbindService(serviceConnection);
+ }
+ }
+
+ /**
+ * @see Fragment#onCreateOptionsMenu(android.view.Menu,
+ * android.view.MenuInflater)
+ */
+ @Override
+ public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
+ // I confess that I don't understand why using the inflater passed as
+ // an argument here doesn't work -- but if you do it crashes without
+ // a stracktrace on API 7.
+ MenuInflater i = mActivity.getMenuInflater();
+ i.inflate(R.menu.now_playing_fragment, menu);
+ PlayerViewLogic.inflatePlayerActions(mActivity, i, menu);
+
+ menu_item_search = menu.findItem(R.id.menu_item_search);
+ menu_item_disconnect = menu.findItem(R.id.menu_item_disconnect);
+
+ menu_item_toggle_power = menu.findItem(R.id.toggle_power);
+ menu_item_sleep = menu.findItem(R.id.sleep);
+ menu_item_sleep_at_end_of_song = menu.findItem(R.id.end_of_song);
+ menu_item_cancel_sleep = menu.findItem(R.id.cancel_sleep);
+
+ menu_item_players = menu.findItem(R.id.menu_item_players);
+ menu_item_alarm = menu.findItem(R.id.menu_item_alarm);
+ }
+
+ /**
+ * Sets the state of assorted option menu items based on whether or not there is a connection to
+ * the server, and if so, whether any players are connected.
+ */
+ @Override
+ public void onPrepareOptionsMenu(@NonNull Menu menu) {
+ boolean connected = isConnected();
+
+ // These are all set at the same time, so one check is sufficient
+ if (menu_item_disconnect != null) {
+ // Set visibility and enabled state of menu items that are not player-specific.
+ menu_item_search.setVisible(globalSearch != null);
+ menu_item_disconnect.setVisible(connected);
+
+ // Set visibility and enabled state of menu items that are player-specific and
+ // require a connection to the server.
+ boolean haveConnectedPlayers = connected && mService != null
+ && !mService.getPlayers().isEmpty();
+
+ menu_item_players.setVisible(haveConnectedPlayers);
+ menu_item_alarm.setVisible(haveConnectedPlayers);
+ menu_item_sleep.setVisible(haveConnectedPlayers);
+ }
+
+ // Don't show the item to go to players if in PlayersActivity.
+ if (mActivity instanceof PlayerListActivity && menu_item_players != null) {
+ menu_item_players.setVisible(false);
+ }
+
+ // Don't show the item to go to alarms if in AlarmsActivity.
+ if (mActivity instanceof AlarmsActivity && menu_item_alarm != null) {
+ menu_item_alarm.setVisible(false);
+ }
+
+ updatePlayerMenuItems();
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(@NonNull MenuItem item) {
+ if (PlayerViewLogic.doPlayerAction(mService, item, getActivePlayer())) {
+ return true;
+ }
+
+ switch (item.getItemId()) {
+ case R.id.menu_item_search:
+ JiveItemListActivity.show(mActivity, globalSearch, globalSearch.goAction);
+ return true;
+ case R.id.menu_item_settings:
+ SettingsActivity.show(mActivity);
+ return true;
+ case R.id.menu_item_disconnect:
+ new Preferences(mActivity).setManualDisconnect(true);
+ mService.disconnect();
+ return true;
+ case R.id.menu_item_players:
+ PlayerListActivity.show(mActivity);
+ return true;
+ case R.id.menu_item_alarm:
+ AlarmsActivity.show(mActivity);
+ return true;
+ case R.id.menu_item_about:
+ new AboutDialog().show(getFragmentManager(), "AboutDialog");
+ return true;
+ }
+
+ return super.onOptionsItemSelected(item);
+ }
+
+ /**
+ * Has the user manually disconnected from the server?
+ *
+ * @return true if they have, false otherwise.
+ */
+ private boolean isManualDisconnect(Context context) {
+ return new Preferences(context).isManualDisconnect();
+ }
+
+ public void startVisibleConnection() {
+ Log.v(TAG, "startVisibleConnection");
+ if (mService == null) {
+ return;
+ }
+
+ Preferences preferences = new Preferences(mActivity);
+
+ // If we are configured to automatically connect on Wi-Fi availability
+ // we will also give the user the opportunity to enable Wi-Fi
+ if (preferences.isAutoConnect()) {
+ WifiManager wifiManager = (WifiManager) mActivity
+ .getApplicationContext().getSystemService(Context.WIFI_SERVICE);
+ if (!wifiManager.isWifiEnabled()) {
+ FragmentManager fragmentManager = getFragmentManager();
+ if (fragmentManager == null) {
+ Log.i(TAG, "fragment manager is null so we can't show EnableWifiDialog");
+ return;
+ }
+
+ FragmentTransaction ft = fragmentManager.beginTransaction();
+ Fragment prev = fragmentManager.findFragmentByTag(EnableWifiDialog.TAG);
+ if (prev != null) {
+ ft.remove(prev);
+ }
+ ft.addToBackStack(null);
+
+ // Create and show the dialog.
+ DialogFragment enableWifiDialog = new EnableWifiDialog();
+ enableWifiDialog.show(ft, EnableWifiDialog.TAG);
+ return;
+ // When a Wi-Fi connection is made this method will be called again by the
+ // broadcastReceiver
+ }
+ }
+
+ if (!preferences.hasServerConfig()) {
+ // Set up a server connection, if it is not present
+ ConnectActivity.show(mActivity);
+ return;
+ }
+
+ if (isConnectInProgress()) {
+ Log.v(TAG, "Connection is already in progress, connecting aborted");
+ return;
+ }
+ mService.startConnect();
+ }
+
+
+ @MainThread
+ public void onEventMainThread(ConnectionChanged event) {
+ Log.d(TAG, "ConnectionChanged: " + event);
+
+ // The fragment may no longer be attached to the parent activity. If so, do nothing.
+ if (!isAdded()) {
+ return;
+ }
+
+ // Handle any of the reasons for disconnection, clear the dialog and show the
+ // ConnectActivity.
+ if (event.connectionState == ConnectionState.DISCONNECTED) {
+ dismissConnectingDialog();
+ ConnectActivity.show(mActivity);
+ return;
+ }
+
+ if (event.connectionState == ConnectionState.CONNECTION_FAILED) {
+ dismissConnectingDialog();
+ switch (event.connectionError) {
+ case LOGIN_FALIED:
+ ConnectActivity.showLoginFailed(mActivity);
+ break;
+ case INVALID_URL:
+ ConnectActivity.showInvalidUrl(mActivity);
+ break;
+ case START_CLIENT_ERROR:
+ case CONNECTION_ERROR:
+ ConnectActivity.showConnectionFailed(mActivity);
+ break;
+ }
+ return;
+ }
+
+ if (event.connectionState == ConnectionState.RECONNECT) {
+ dismissConnectingDialog();
+ HomeActivity.show(mActivity);
+ return;
+ }
+
+ // Any other event means that a connection is in progress or completed.
+ // Show the the dialog if appropriate.
+ if (event.connectionState != ConnectionState.CONNECTION_COMPLETED) {
+ showConnectingDialog();
+ }
+
+ // Ensure that option menu item state is adjusted as appropriate.
+ getActivity().supportInvalidateOptionsMenu();
+
+ disableButton(nextButton);
+ disableButton(prevButton);
+
+ if (mFullHeightLayout) {
+ shuffleButton.setEnabled(false);
+ repeatButton.setEnabled(false);
+ volumeButton.setEnabled(false);
+ playlistButton.setEnabled(false);
+
+ albumArt.setImageResource(R.drawable.icon_album_noart_fullscreen);
+ shuffleButton.setImageResource(0);
+ repeatButton.setImageResource(0);
+ updatePlayerDropDown(Collections.emptyList(), null);
+ artistText.setText(getText(R.string.disconnected_text));
+ btnContextMenu.setVisibility(View.GONE);
+ currentTime.setText("--:--");
+ totalTime.setText("--:--");
+ seekBar.setEnabled(false);
+ seekBar.setProgress(0);
+ } else {
+ albumArt.setImageResource(R.drawable.icon_pending_artwork);
+ mProgressBar.setEnabled(false);
+ mProgressBar.setProgress(0);
+ }
+ }
+
+ @MainThread
+ public void onEventMainThread(HandshakeComplete event) {
+ // Event might arrive before this fragment has connected to the service (e.g.,
+ // the activity connected before this fragment did).
+ // XXX: Verify that this is possible, since the fragment can't register for events
+ // until it's connected to the service.
+ if (mService == null) {
+ return;
+ }
+
+ Log.d(TAG, "Handshake complete");
+
+ dismissConnectingDialog();
+
+ enableButton(nextButton);
+ enableButton(prevButton);
+ if (mFullHeightLayout) {
+ shuffleButton.setEnabled(true);
+ repeatButton.setEnabled(true);
+ seekBar.setEnabled(true);
+ volumeButton.setEnabled(true);
+ playlistButton.setEnabled(true);
+ } else {
+ mProgressBar.setEnabled(true);
+ }
+
+ PlayerState playerState = getPlayerState();
+
+ // May be no players connected.
+ // TODO: These views should be cleared if there's no player connected.
+ if (playerState == null)
+ return;
+
+ updateUiFromPlayerState(playerState);
+ }
+
+ @MainThread
+ public void onEventMainThread(@SuppressWarnings("unused") RegisterSqueezeNetwork event) {
+ // We're connected but the controller needs to register with the server
+ JiveItemListActivity.register(mActivity);
+ }
+
+ @MainThread
+ public void onEventMainThread(MusicChanged event) {
+ if (event.player.equals(mService.getActivePlayer())) {
+ updateSongInfo(event.playerState);
+ }
+ }
+
+ @MainThread
+ public void onEventMainThread(PlayersChanged event) {
+ updatePlayerDropDown(event.players.values(), mService.getActivePlayer());
+ }
+
+ @MainThread
+ public void onEventMainThread(PlayStatusChanged event) {
+ if (event.player.equals(mService.getActivePlayer())) {
+ updatePlayPauseIcon(event.playStatus);
+ }
+ }
+
+ @MainThread
+ public void onEventMainThread(PowerStatusChanged event) {
+ if (event.player.equals(mService.getActivePlayer())) {
+ updatePlayerMenuItems();
+ }
+ }
+
+ @MainThread
+ public void onEventMainThread(HomeMenuEvent event) {
+ globalSearch = null;
+ for (JiveItem menuItem : event.menuItems) {
+ if ("globalSearch".equals(menuItem.getId()) && menuItem.goAction != null) {
+ globalSearch = menuItem;
+ break;
+ }
+ }
+ if (menu_item_search != null) {
+ menu_item_search.setVisible(globalSearch != null);
+ }
+ }
+
+ @MainThread
+ public void onEventMainThread(RepeatStatusChanged event) {
+ if (event.player.equals(mService.getActivePlayer())) {
+ updateRepeatStatus(event.repeatStatus);
+ }
+ }
+
+ @MainThread
+ public void onEventMainThread(ShuffleStatusChanged event) {
+ if (event.player.equals(mService.getActivePlayer())) {
+ updateShuffleStatus(event.shuffleStatus);
+ }
+ }
+
+ @MainThread
+ public void onEventMainThread(SongTimeChanged event) {
+ if (event.player.equals(mService.getActivePlayer())) {
+ updateTimeDisplayTo(event.currentPosition, event.duration);
+ }
+ }
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/Preferences.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/Preferences.java
new file mode 100644
index 000000000..59f0d1b9a
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/Preferences.java
@@ -0,0 +1,476 @@
+/*
+ * Copyright (c) 2009 Google Inc. 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 uk.org.ngo.squeezer;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.content.res.Configuration;
+import android.net.wifi.WifiInfo;
+import android.net.wifi.WifiManager;
+import android.util.Log;
+
+import java.util.Random;
+import java.util.UUID;
+
+import uk.org.ngo.squeezer.download.DownloadFilenameStructure;
+import uk.org.ngo.squeezer.download.DownloadPathStructure;
+import uk.org.ngo.squeezer.itemlist.dialog.ArtworkListLayout;
+import uk.org.ngo.squeezer.util.ThemeManager;
+
+public final class Preferences {
+ private static final String TAG = Preferences.class.getSimpleName();
+
+ public static final String NAME = "Squeezer";
+
+ // Old setting for connect via the CLI protocol
+ private static final String KEY_CLI_SERVER_ADDRESS = "squeezer.serveraddr";
+
+ // Squeezebox server address (host:port)
+ private static final String KEY_SERVER_ADDRESS = "squeezer.server_addr";
+
+ // Do we connect to mysqueezebox.com
+ private static final String KEY_SQUEEZE_NETWORK = "squeezer.squeeze_network";
+
+ // Optional Squeezebox Server name
+ private static final String KEY_SERVER_NAME = "squeezer.server_name";
+
+ // Optional Squeezebox Server user name
+ private static final String KEY_USERNAME = "squeezer.username";
+
+ // Optional Squeezebox Server password
+ private static final String KEY_PASSWORD = "squeezer.password";
+
+ // The playerId that we were last connected to. e.g. "00:04:20:17:04:7f"
+ public static final String KEY_LAST_PLAYER = "squeezer.lastplayer";
+
+ // Do we automatically try and connect on WiFi availability?
+ public static final String KEY_AUTO_CONNECT = "squeezer.autoconnect";
+
+ // Are we disconnected via the options menu?
+ private static final String KEY_MANUAL_DISCONNECT = "squeezer.manual.disconnect";
+
+ // Type of notification to show. NOT USED ANYMORE
+ private static final String KEY_NOTIFICATION_TYPE = "squeezer.notification_type";
+
+ // Do we scrobble track information?
+ // Deprecated, retained for compatibility when upgrading. Was an int, of
+ // either 0 == No scrobbling, 1 == use ScrobbleDroid API, 2 == use SLS API
+ public static final String KEY_SCROBBLE = "squeezer.scrobble";
+
+ // Do we scrobble track information (if a scrobble service is available)?
+ //
+ // Type of underlying preference is bool / CheckBox
+ public static final String KEY_SCROBBLE_ENABLED = "squeezer.scrobble.enabled";
+
+ // Do we send anonymous usage statistics?
+ public static final String KEY_ANALYTICS_ENABLED = "squeezer.analytics.enabled";
+
+ // Fade-in period? (0 = disable fade-in)
+ public static final String KEY_FADE_IN_SECS = "squeezer.fadeInSecs";
+
+ // What do to when an album is selected in the list view
+ private static final String KEY_ON_SELECT_ALBUM_ACTION = "squeezer.action.onselect.album";
+
+ // What do to when a song is selected in the list view
+ private static final String KEY_ON_SELECT_SONG_ACTION = "squeezer.action.onselect.song";
+
+ // Preferred album list layout.
+ private static final String KEY_ALBUM_LIST_LAYOUT = "squeezer.album.list.layout";
+
+ // Preferred home menu layout.
+ private static final String KEY_HOME_MENU_LAYOUT = "squeezer.home.menu.layout";
+
+ // Preferred song list layout.
+ private static final String KEY_SONG_LIST_LAYOUT = "squeezer.song.list.layout";
+
+ // Start SqueezePlayer automatically if installed.
+ public static final String KEY_SQUEEZEPLAYER_ENABLED = "squeezer.squeezeplayer.enabled";
+
+ // Preferred UI theme.
+ static final String KEY_ON_THEME_SELECT_ACTION = "squeezer.theme";
+
+ // Download enabled
+ static final String KEY_DOWNLOAD_ENABLED = "squeezer.download.enabled";
+
+ // Download confirmation
+ static final String KEY_DOWNLOAD_CONFIRMATION = "squeezer.download.confirmation";
+
+ // Download folder
+ static final String KEY_DOWNLOAD_USE_SERVER_PATH = "squeezer.download.use_server_path";
+
+ // Download path structure
+ static final String KEY_DOWNLOAD_PATH_STRUCTURE = "squeezer.download.path_structure";
+
+ // Download filename structure
+ static final String KEY_DOWNLOAD_FILENAME_STRUCTURE = "squeezer.download.filename_structure";
+
+ // Use SD-card (getExternalMediaDirs)
+ static final String KEY_DOWNLOAD_USE_SD_CARD_SCREEN = "squeezer.download.use_sd_card.screen";
+ static final String KEY_DOWNLOAD_USE_SD_CARD = "squeezer.download.use_sd_card";
+
+ // Store a "mac id" for this app instance.
+ private static final String KEY_MAC_ID = "squeezer.mac_id";
+
+ // Store a unique id for this app instance.
+ private static final String KEY_UUID = "squeezer.uuid";
+
+ private final Context context;
+ private final SharedPreferences sharedPreferences;
+ private final int defaultCliPort;
+ private final int defaultHttpPort;
+
+ public Preferences(Context context) {
+ this(context, context.getSharedPreferences(Preferences.NAME, Context.MODE_PRIVATE));
+ }
+
+ public Preferences(Context context, SharedPreferences sharedPreferences) {
+ this.context = context;
+ this.sharedPreferences = sharedPreferences;
+ defaultCliPort = context.getResources().getInteger(R.integer.DefaultCliPort);
+ defaultHttpPort = context.getResources().getInteger(R.integer.DefaultHttpPort);
+ }
+
+ private String getStringPreference(String preference) {
+ final String pref = sharedPreferences.getString(preference, null);
+ if (pref == null || pref.length() == 0) {
+ return null;
+ }
+ return pref;
+ }
+
+ public boolean hasServerConfig() {
+ String bssId = getBssId();
+ return (sharedPreferences.contains(prefixed(bssId, KEY_SERVER_ADDRESS)) ||
+ sharedPreferences.contains(KEY_SERVER_ADDRESS));
+ }
+
+ public ServerAddress getServerAddress() {
+ return getServerAddress(KEY_SERVER_ADDRESS, defaultHttpPort);
+ }
+
+ public ServerAddress getCliServerAddress() {
+ return getServerAddress(KEY_CLI_SERVER_ADDRESS, defaultCliPort);
+ }
+
+ private ServerAddress getServerAddress(String setting, int defaultPort) {
+ ServerAddress serverAddress = new ServerAddress(defaultPort);
+
+ serverAddress.bssId = getBssId();
+
+ String address = null;
+ if (serverAddress.bssId != null) {
+ address = getStringPreference(setting + "_" + serverAddress.bssId);
+ }
+ if (address == null) {
+ address = getStringPreference(setting);
+ }
+ serverAddress.setAddress(address, defaultPort);
+
+ serverAddress.squeezeNetwork = sharedPreferences.getBoolean(prefixed(serverAddress.bssId, KEY_SQUEEZE_NETWORK), false);
+
+ return serverAddress;
+ }
+
+ private String getBssId() {
+ WifiManager mWifiManager = (WifiManager) context
+ .getApplicationContext().getSystemService(Context.WIFI_SERVICE);
+ WifiInfo connectionInfo = mWifiManager.getConnectionInfo();
+ return (connectionInfo != null ? connectionInfo.getBSSID() : null);
+ }
+
+ private String prefixed(String bssId, String setting) {
+ return (bssId != null ? setting + "_" + bssId : setting);
+ }
+
+ private String prefix(ServerAddress serverAddress) {
+ return (serverAddress.bssId != null ? serverAddress.bssId + "_ " : "") + serverAddress.localAddress() + "_";
+ }
+
+ public static class ServerAddress {
+ private static final String SN = "mysqueezebox.com";
+
+ private String bssId;
+ public boolean squeezeNetwork;
+ private String address; // :
+ private String host;
+ private int port;
+ private final int defaultPort;
+
+ private ServerAddress(int defaultPort) {
+ this.defaultPort = defaultPort;
+ }
+
+ public void setAddress(String hostPort) {
+ setAddress(hostPort, defaultPort);
+ }
+
+ public String address() {
+ return host() + ":" + port();
+ }
+
+ public String localAddress() {
+ if (address == null) {
+ return null;
+ }
+
+ return host + ":" + port;
+ }
+
+ public String host() {
+ return (squeezeNetwork ? SN : host);
+ }
+
+ public String localHost() {
+ return host;
+ }
+
+ public int port() {
+ return (squeezeNetwork ? defaultPort : port);
+ }
+
+ private void setAddress(String hostPort, int defaultPort) {
+ // Common mistakes, based on crash reports...
+ if (hostPort != null) {
+ if (hostPort.startsWith("Http://") || hostPort.startsWith("http://")) {
+ hostPort = hostPort.substring(7);
+ }
+
+ // Ending in whitespace? From LatinIME, probably?
+ while (hostPort.endsWith(" ")) {
+ hostPort = hostPort.substring(0, hostPort.length() - 1);
+ }
+ }
+
+ address = hostPort;
+ host = parseHost();
+ port = parsePort(defaultPort);
+ }
+
+ private String parseHost() {
+ if (address == null) {
+ return "";
+ }
+ int colonPos = address.indexOf(":");
+ if (colonPos == -1) {
+ return address;
+ }
+ return address.substring(0, colonPos);
+ }
+
+ private int parsePort(int defaultPort) {
+ if (address == null) {
+ return defaultPort;
+ }
+ int colonPos = address.indexOf(":");
+ if (colonPos == -1) {
+ return defaultPort;
+ }
+ try {
+ return Integer.parseInt(address.substring(colonPos + 1));
+ } catch (NumberFormatException unused) {
+ Log.d(TAG, "Can't parse port out of " + address);
+ return defaultPort;
+ }
+ }
+ }
+
+ public void saveServerAddress(ServerAddress serverAddress) {
+ SharedPreferences.Editor editor = sharedPreferences.edit();
+ editor.putString(prefixed(serverAddress.bssId, KEY_SERVER_ADDRESS), serverAddress.address);
+ editor.putBoolean(prefixed(serverAddress.bssId, KEY_SQUEEZE_NETWORK), serverAddress.squeezeNetwork);
+ editor.apply();
+ }
+
+ public String getServerName(ServerAddress serverAddress) {
+ if (serverAddress.squeezeNetwork) {
+ return ServerAddress.SN;
+ }
+ String serverName = getStringPreference(prefix(serverAddress) + KEY_SERVER_NAME);
+ return serverName != null ? serverName : serverAddress.host;
+ }
+
+ public void saveServerName(ServerAddress serverAddress, String serverName) {
+ SharedPreferences.Editor editor = sharedPreferences.edit();
+ editor.putString(prefix(serverAddress) + KEY_SERVER_NAME, serverName);
+ editor.apply();
+ }
+
+ public String getUsername(ServerAddress serverAddress) {
+ return getStringPreference(prefix(serverAddress) + KEY_USERNAME);
+ }
+
+ public String getPassword(ServerAddress serverAddress) {
+ return getStringPreference(prefix(serverAddress) + KEY_PASSWORD);
+ }
+
+ public void saveUserCredentials(ServerAddress serverAddress, String userName, String password) {
+ SharedPreferences.Editor editor = sharedPreferences.edit();
+ editor.putString(prefix(serverAddress) + KEY_USERNAME, userName);
+ editor.putString(prefix(serverAddress) + KEY_PASSWORD, password);
+ editor.apply();
+ }
+
+ public String getTheme() {
+ return getStringPreference(KEY_ON_THEME_SELECT_ACTION);
+ }
+
+ public void setTheme(ThemeManager.Theme theme) {
+ SharedPreferences.Editor editor = sharedPreferences.edit();
+ editor.putString(Preferences.KEY_ON_THEME_SELECT_ACTION, theme.name());
+ editor.apply();
+ }
+
+ public boolean isAutoConnect() {
+ return sharedPreferences.getBoolean(KEY_AUTO_CONNECT, true);
+ }
+
+ public boolean isManualDisconnect() {
+ return sharedPreferences.getBoolean(KEY_MANUAL_DISCONNECT, false);
+ }
+
+ public void setManualDisconnect(boolean manualDisconnect) {
+ SharedPreferences.Editor editor = sharedPreferences.edit();
+ editor.putBoolean(Preferences.KEY_MANUAL_DISCONNECT, manualDisconnect);
+ editor.apply();
+ }
+
+ public boolean controlSqueezePlayer(ServerAddress serverAddress) {
+ return (!serverAddress.squeezeNetwork && sharedPreferences.getBoolean(KEY_SQUEEZEPLAYER_ENABLED, true));
+ }
+
+ /** Get the preferred album list layout. */
+ public ArtworkListLayout getAlbumListLayout() {
+ return getListLayout(KEY_ALBUM_LIST_LAYOUT);
+ }
+
+ public void setAlbumListLayout(ArtworkListLayout artworkListLayout) {
+ setListLayout(KEY_ALBUM_LIST_LAYOUT, artworkListLayout);
+ }
+
+ /** Get the preferred home menu layout. */
+ public ArtworkListLayout getHomeMenuLayout() {
+ return getListLayout(KEY_HOME_MENU_LAYOUT);
+ }
+
+ public void setHomeMenuLayout(ArtworkListLayout artworkListLayout) {
+ setListLayout(KEY_HOME_MENU_LAYOUT, artworkListLayout);
+ }
+
+ /**
+ * Get the preferred layout for the specified preference
+ *
+ * If the list layout is not selected, a default one is chosen, based on the current screen
+ * size, on the assumption that the artwork grid is preferred on larger screens.
+ */
+ private ArtworkListLayout getListLayout(String preference) {
+ String listLayoutString = sharedPreferences.getString(preference, null);
+ if (listLayoutString == null) {
+ int screenSize = context.getResources().getConfiguration().screenLayout
+ & Configuration.SCREENLAYOUT_SIZE_MASK;
+ return (screenSize >= Configuration.SCREENLAYOUT_SIZE_LARGE)
+ ? ArtworkListLayout.grid : ArtworkListLayout.list;
+ } else {
+ return ArtworkListLayout.valueOf(listLayoutString);
+ }
+ }
+
+ private void setListLayout(String preference, ArtworkListLayout artworkListLayout) {
+ SharedPreferences.Editor editor = sharedPreferences.edit();
+ editor.putString(preference, artworkListLayout.name());
+ editor.apply();
+ }
+
+ /**
+ * Retrieve a "mac id" for this app instance.
+ *
+ * If a mac id is previously stored, then use it, otherwise create a new mac id
+ * store it and return it.
+ */
+ public String getMacId() {
+ String macId = sharedPreferences.getString(KEY_MAC_ID, null);
+ if (macId == null) {
+ macId = generateMacLikeId();
+ SharedPreferences.Editor editor = sharedPreferences.edit();
+ editor.putString(Preferences.KEY_MAC_ID, macId);
+ editor.apply();
+ }
+ return macId;
+ }
+
+ /**
+ * As Android (6.0 and above) does not allow acces to the mac id, and mysqueezebox.com requires
+ * it, this is the best I can think of.
+ */
+ private String generateMacLikeId() {
+ StringBuilder sb = new StringBuilder(18);
+ byte[] b = new byte[6];
+ new Random().nextBytes(b);
+ for (int i = 0; i < b.length; i++) {
+ sb.append(String.format("%02X:", b[i]));
+ }
+ sb.deleteCharAt(sb.length() - 1);
+ return sb.toString();
+ }
+
+ /**
+ * Retrieve a unique id (uuid) for this app instance.
+ *
+ * If a uuid is previously stored, then use it, otherwise create a new uuid,
+ * store it and return it.
+ */
+ public String getUuid() {
+ String uuid = sharedPreferences.getString(KEY_UUID, null);
+ if (uuid == null) {
+ //NOTE mysqueezebox.com doesn't accept dash in the uuid
+ uuid = UUID.randomUUID().toString().replaceAll("-", "");
+ SharedPreferences.Editor editor = sharedPreferences.edit();
+ editor.putString(Preferences.KEY_UUID, uuid);
+ editor.apply();
+ }
+ return uuid;
+ }
+
+ public boolean isDownloadEnabled() {
+ return sharedPreferences.getBoolean(KEY_DOWNLOAD_ENABLED, true);
+ }
+
+ public void setDownloadEnabled(boolean b) {
+ sharedPreferences.edit().putBoolean(Preferences.KEY_DOWNLOAD_ENABLED, b).apply();
+ }
+
+ public boolean isDownloadConfirmation() {
+ return sharedPreferences.getBoolean(KEY_DOWNLOAD_CONFIRMATION, true);
+ }
+
+ public void setDownloadConfirmation(boolean b) {
+ sharedPreferences.edit().putBoolean(Preferences.KEY_DOWNLOAD_CONFIRMATION, b).apply();
+ }
+
+ public boolean isDownloadUseServerPath() {
+ return sharedPreferences.getBoolean(KEY_DOWNLOAD_USE_SERVER_PATH, true);
+ }
+
+ public DownloadPathStructure getDownloadPathStructure() {
+ final String string = sharedPreferences.getString(KEY_DOWNLOAD_PATH_STRUCTURE, null);
+ return (string == null ? DownloadPathStructure.ARTIST_ALBUM: DownloadPathStructure.valueOf(string));
+ }
+
+ public DownloadFilenameStructure getDownloadFilenameStructure() {
+ final String string = sharedPreferences.getString(KEY_DOWNLOAD_FILENAME_STRUCTURE, null);
+ return (string == null ? DownloadFilenameStructure.NUMBER_TITLE: DownloadFilenameStructure.valueOf(string));
+ }
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/SettingsActivity.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/SettingsActivity.java
new file mode 100644
index 000000000..b2e5df681
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/SettingsActivity.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2009 Google Inc. 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 uk.org.ngo.squeezer;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+
+import androidx.appcompat.app.AppCompatActivity;
+
+import uk.org.ngo.squeezer.util.ThemeManager;
+
+public class SettingsActivity extends AppCompatActivity {
+
+ private final ThemeManager mThemeManager = new ThemeManager();
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ mThemeManager.onCreate(this);
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.settings);
+ }
+
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ mThemeManager.onResume(this);
+ }
+
+ public static void show(Context context) {
+ final Intent intent = new Intent(context, SettingsActivity.class);
+ context.startActivity(intent);
+ }
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/SettingsFragment.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/SettingsFragment.java
new file mode 100644
index 000000000..4921ac64d
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/SettingsFragment.java
@@ -0,0 +1,317 @@
+package uk.org.ngo.squeezer;
+
+import android.content.ActivityNotFoundException;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.content.SharedPreferences;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.ListView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.DialogFragment;
+import androidx.preference.CheckBoxPreference;
+import androidx.preference.ListPreference;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceFragmentCompat;
+import androidx.preference.SwitchPreferenceCompat;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import java.util.ArrayList;
+
+import uk.org.ngo.squeezer.download.DownloadFilenameStructure;
+import uk.org.ngo.squeezer.download.DownloadPathStructure;
+import uk.org.ngo.squeezer.framework.EnumWithText;
+import uk.org.ngo.squeezer.service.ISqueezeService;
+import uk.org.ngo.squeezer.service.SqueezeService;
+import uk.org.ngo.squeezer.util.Scrobble;
+import uk.org.ngo.squeezer.util.ThemeManager;
+
+public class SettingsFragment extends PreferenceFragmentCompat implements
+ Preference.OnPreferenceChangeListener, SharedPreferences.OnSharedPreferenceChangeListener {
+
+ private final String TAG = "SettingsActivity";
+
+ private ISqueezeService service = null;
+
+ private IntEditTextPreference fadeInPref;
+
+ private final ServiceConnection serviceConnection = new ServiceConnection() {
+ @Override
+ public void onServiceConnected(ComponentName name, IBinder service) {
+ SettingsFragment.this.service = (ISqueezeService) service;
+ }
+
+ @Override
+ public void onServiceDisconnected(ComponentName name) {
+ service = null;
+ }
+ };
+
+ @Override
+ public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
+ getActivity().bindService(new Intent(getActivity(), SqueezeService.class), serviceConnection,
+ Context.BIND_AUTO_CREATE);
+ Log.d(TAG, "did bindService; service = " + service);
+
+ getPreferenceManager().setSharedPreferencesName(Preferences.NAME);
+ setPreferencesFromResource(R.xml.preferences, rootKey);
+
+ SharedPreferences sharedPreferences = getPreferenceManager().getSharedPreferences();
+ sharedPreferences.registerOnSharedPreferenceChangeListener(this);
+ Preferences preferences = new Preferences(getActivity(), sharedPreferences);
+
+ fadeInPref = findPreference(Preferences.KEY_FADE_IN_SECS);
+ fadeInPref.setOnPreferenceChangeListener(this);
+ updateFadeInSecondsSummary(sharedPreferences.getInt(Preferences.KEY_FADE_IN_SECS, 0));
+
+ SwitchPreferenceCompat autoConnectPref = findPreference(Preferences.KEY_AUTO_CONNECT);
+ autoConnectPref.setChecked(sharedPreferences.getBoolean(Preferences.KEY_AUTO_CONNECT, true));
+
+ fillScrobblePreferences(sharedPreferences);
+
+ fillDownloadPreferences(preferences);
+ fillThemeSelectionPreferences();
+
+ SwitchPreferenceCompat startSqueezePlayerPref = findPreference(
+ Preferences.KEY_SQUEEZEPLAYER_ENABLED);
+ startSqueezePlayerPref.setChecked(sharedPreferences.getBoolean(Preferences.KEY_SQUEEZEPLAYER_ENABLED, true));
+ }
+
+ private void fillScrobblePreferences(SharedPreferences preferences) {
+ SwitchPreferenceCompat scrobblePref = findPreference(Preferences.KEY_SCROBBLE_ENABLED);
+ scrobblePref.setOnPreferenceChangeListener(this);
+
+ if (!Scrobble.canScrobble()) {
+ scrobblePref.setSummaryOff(getString(R.string.settings_scrobble_noapp));
+ scrobblePref.setChecked(false);
+ } else {
+ scrobblePref.setSummaryOff(getString(R.string.settings_scrobble_off));
+
+ scrobblePref
+ .setChecked(preferences.getBoolean(Preferences.KEY_SCROBBLE_ENABLED, false));
+
+ // If an old KEY_SCROBBLE preference exists, use it, delete it, and
+ // upgrade it to the new KEY_SCROBBLE_ENABLED preference.
+ if (preferences.contains(Preferences.KEY_SCROBBLE)) {
+ boolean enabled = (Integer.parseInt(
+ preferences.getString(Preferences.KEY_SCROBBLE, "0")) > 0);
+ scrobblePref.setChecked(enabled);
+ SharedPreferences.Editor editor = preferences.edit();
+ editor.putBoolean(Preferences.KEY_SCROBBLE_ENABLED, enabled);
+ editor.remove(Preferences.KEY_SCROBBLE);
+ editor.apply();
+ }
+ }
+ }
+
+ private void fillDownloadPreferences(Preferences preferences) {
+ final ListPreference pathStructurePreference = findPreference(Preferences.KEY_DOWNLOAD_PATH_STRUCTURE);
+ final ListPreference filenameStructurePreference = findPreference(Preferences.KEY_DOWNLOAD_FILENAME_STRUCTURE);
+
+ fillEnumPreference(pathStructurePreference, DownloadPathStructure.class, preferences.getDownloadPathStructure());
+ fillEnumPreference(filenameStructurePreference, DownloadFilenameStructure.class, preferences.getDownloadFilenameStructure());
+
+ updateDownloadPreferences(preferences);
+ }
+
+ private void updateDownloadPreferences(Preferences preferences) {
+ final SwitchPreferenceCompat downloadEnabled = findPreference(Preferences.KEY_DOWNLOAD_ENABLED);
+ final CheckBoxPreference downloadConfirmation = findPreference(Preferences.KEY_DOWNLOAD_CONFIRMATION);
+ final CheckBoxPreference useServerPathPreference = findPreference(Preferences.KEY_DOWNLOAD_USE_SERVER_PATH);
+ final ListPreference pathStructurePreference = findPreference(Preferences.KEY_DOWNLOAD_PATH_STRUCTURE);
+ final ListPreference filenameStructurePreference = findPreference(Preferences.KEY_DOWNLOAD_FILENAME_STRUCTURE);
+ final boolean enabled = preferences.isDownloadEnabled();
+ final boolean useServerPath = preferences.isDownloadUseServerPath();
+
+ downloadEnabled.setChecked(enabled);
+ downloadConfirmation.setChecked(preferences.isDownloadConfirmation());
+ useServerPathPreference.setChecked(useServerPath);
+
+ downloadConfirmation.setEnabled(enabled);
+ useServerPathPreference.setEnabled(enabled);
+ pathStructurePreference.setEnabled(enabled && !useServerPath);
+ filenameStructurePreference.setEnabled(enabled && !useServerPath);
+ }
+
+ private void fillThemeSelectionPreferences() {
+ ListPreference onSelectThemePref = findPreference(Preferences.KEY_ON_THEME_SELECT_ACTION);
+ ArrayList entryValues = new ArrayList<>();
+ ArrayList entries = new ArrayList<>();
+
+ for (ThemeManager.Theme theme : ThemeManager.Theme.values()) {
+ entryValues.add(theme.name());
+ entries.add(theme.getText(getActivity()));
+ }
+
+ onSelectThemePref.setEntryValues(entryValues.toArray(new String[entryValues.size()]));
+ onSelectThemePref.setEntries(entries.toArray(new String[0]));
+ onSelectThemePref.setDefaultValue(ThemeManager.getDefaultTheme().name());
+ if (onSelectThemePref.getValue() == null) {
+ onSelectThemePref.setValue(ThemeManager.getDefaultTheme().name());
+ } else {
+ try {
+ ThemeManager.Theme t = ThemeManager.Theme.valueOf(onSelectThemePref.getValue());
+ } catch (Exception e) {
+ onSelectThemePref.setValue(ThemeManager.getDefaultTheme().name());
+ }
+ }
+ onSelectThemePref.setOnPreferenceChangeListener(this);
+ updateListPreferenceSummary(onSelectThemePref, onSelectThemePref.getValue());
+ }
+
+ private & EnumWithText> void fillEnumPreference(ListPreference listPreference, Class actionTypes, E defaultValue) {
+ fillEnumPreference(listPreference, actionTypes.getEnumConstants(), defaultValue);
+ }
+
+ private & EnumWithText> void fillEnumPreference(ListPreference listPreference, E[] actionTypes, E defaultValue) {
+ String[] values = new String[actionTypes.length];
+ String[] entries = new String[actionTypes.length];
+ for (int i = 0; i < actionTypes.length; i++) {
+ values[i] = actionTypes[i].name();
+ entries[i] = actionTypes[i].getText(getActivity());
+ }
+ listPreference.setEntryValues(values);
+ listPreference.setEntries(entries);
+ listPreference.setDefaultValue(defaultValue);
+ if (listPreference.getValue() == null) {
+ listPreference.setValue(defaultValue.name());
+ }
+ listPreference.setOnPreferenceChangeListener(this);
+ updateListPreferenceSummary(listPreference, listPreference.getValue());
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ getActivity().unbindService(serviceConnection);
+ }
+
+ private void updateFadeInSecondsSummary(int fadeInSeconds) {
+ if (fadeInSeconds == 0) {
+ fadeInPref.setSummary(R.string.disabled);
+ } else {
+ fadeInPref.setSummary(fadeInSeconds + " " + getResources()
+ .getQuantityString(R.plurals.seconds, fadeInSeconds));
+ }
+ }
+
+ /**
+ * Explicitly set the preference's summary based on the value for the selected item.
+ *
+ * Work around a bug in ListPreference on devices running earlier API versions (not
+ * sure when the bug starts) where the preference summary string is not automatically
+ * updated when the preference changes. See http://stackoverflow.com/a/7018053/775306
+ * for details.
+ *
+ * @param pref the preference to set
+ * @param value the preference's value (might not be set yet)
+ */
+ private void updateListPreferenceSummary(ListPreference pref, String value) {
+ CharSequence[] entries = pref.getEntries();
+ int index = pref.findIndexOfValue(value);
+ if (index != -1) pref.setSummary(entries[index]);
+ }
+
+ /**
+ * A preference has been changed by the user, but has not yet been persisted.
+ */
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newValue) {
+ final String key = preference.getKey();
+ Log.v(TAG, "preference change for: " + key);
+
+ if (Preferences.KEY_FADE_IN_SECS.equals(key)) {
+ updateFadeInSecondsSummary(Util.getInt(newValue.toString()));
+ }
+
+ if (Preferences.KEY_ON_THEME_SELECT_ACTION.equals(key) ||
+ Preferences.KEY_DOWNLOAD_PATH_STRUCTURE.equals(key) ||
+ Preferences.KEY_DOWNLOAD_FILENAME_STRUCTURE.equals(key)) {
+ updateListPreferenceSummary((ListPreference) preference, (String) newValue);
+ }
+
+ // If the user has enabled Scrobbling but we don't think it will work
+ // pop up a dialog with links to Google Play for apps to install.
+ if (Preferences.KEY_SCROBBLE_ENABLED.equals(key)) {
+ if (newValue.equals(true) && !Scrobble.canScrobble()) {
+ new ScrobbleAppsDialog().show(getFragmentManager(), TAG);
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * A preference has been changed by the user and is going to be persisted.
+ */
+ @Override
+ public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
+ Log.v(TAG, "Preference changed: " + key);
+
+ if (key.equals(Preferences.KEY_DOWNLOAD_USE_SERVER_PATH) ||
+ key.equals(Preferences.KEY_DOWNLOAD_ENABLED)
+ ) {
+ updateDownloadPreferences(new Preferences(getActivity(), sharedPreferences));
+ }
+
+ if (service != null) {
+ service.preferenceChanged(key);
+ } else {
+ Log.v(TAG, "service is null!");
+ }
+ }
+
+ public static class ScrobbleAppsDialog extends DialogFragment {
+ @NonNull
+ @Override
+ public AlertDialog onCreateDialog(Bundle savedInstanceState) {
+ final CharSequence[] apps = {
+ "Last.fm", "ScrobbleDroid", "SLS"
+ };
+ final CharSequence[] urls = {
+ "fm.last.android", "net.jjc1138.android.scrobbler",
+ "com.adam.aslfms"
+ };
+
+ final int[] icons = {
+ R.drawable.ic_launcher_lastfm,
+ R.drawable.ic_launcher_scrobbledroid, R.drawable.ic_launcher_sls
+ };
+
+ final View dialogView = LayoutInflater.from(getActivity()).inflate(R.layout.scrobbler_choice_dialog, null);
+ AlertDialog dialog = new MaterialAlertDialogBuilder(getActivity())
+ .setView(dialogView)
+ .setTitle("Scrobbling applications")
+ .create();
+
+ ListView appList = dialogView.findViewById(R.id.scrobble_apps);
+ appList.setAdapter(new IconRowAdapter(getActivity(), apps, icons));
+
+ final Context context = dialog.getContext();
+ appList.setOnItemClickListener((parent, view, position, id1) -> {
+ Intent intent = new Intent(Intent.ACTION_VIEW);
+ intent.setData(Uri.parse("market://details?id=" + urls[position]));
+ try {
+ startActivity(intent);
+ } catch (ActivityNotFoundException e) {
+ Toast.makeText(context, R.string.settings_market_not_found,
+ Toast.LENGTH_SHORT).show();
+ }
+ });
+
+ return dialog;
+ }
+
+ }
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/Squeezer.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/Squeezer.java
new file mode 100644
index 000000000..cc48cee0d
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/Squeezer.java
@@ -0,0 +1,28 @@
+package uk.org.ngo.squeezer;
+
+
+import android.content.Context;
+
+import androidx.multidex.MultiDexApplication;
+
+// Trick to make the app context useful available everywhere.
+// See http://stackoverflow.com/questions/987072/using-application-context-everywhere
+
+public class Squeezer extends MultiDexApplication {
+
+ private static Squeezer instance;
+
+ public Squeezer() {
+ instance = this;
+ }
+
+ public static Context getContext() {
+ return instance;
+ }
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ }
+}
+
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/Util.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/Util.java
new file mode 100644
index 000000000..0ac3d75f8
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/Util.java
@@ -0,0 +1,360 @@
+/*
+ * Copyright (c) 2009 Google Inc. 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 uk.org.ngo.squeezer;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+
+import androidx.annotation.DrawableRes;
+import androidx.annotation.NonNull;
+import androidx.appcompat.content.res.AppCompatResources;
+
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.Formatter;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.regex.Pattern;
+
+public class Util {
+
+ /**
+ * {@link java.util.regex.Pattern} that splits strings on colon.
+ */
+ private static final Pattern mColonSplitPattern = Pattern.compile(":");
+
+ private Util() {
+ }
+
+
+ /**
+ * Update target, if it's different from newValue.
+ *
+ * @return true if target is updated. Otherwise return false.
+ */
+ public static boolean atomicReferenceUpdated(AtomicReference target, T newValue) {
+ T currentValue = target.get();
+ if (currentValue == null && newValue == null) {
+ return false;
+ }
+ if (currentValue == null || !currentValue.equals(newValue)) {
+ target.set(newValue);
+ return true;
+ }
+ return false;
+ }
+
+ public static double parseDouble(String value, double defaultValue) {
+ if (value == null) {
+ return defaultValue;
+ }
+ if (value.length() == 0) {
+ return defaultValue;
+ }
+ try {
+ return Double.parseDouble(value);
+ } catch (NumberFormatException e) {
+ return defaultValue;
+ }
+ }
+
+ public static long parseDecimalInt(String value, long defaultValue) {
+ if (value == null) {
+ return defaultValue;
+ }
+ int decimalPoint = value.indexOf('.');
+ if (decimalPoint != -1) {
+ value = value.substring(0, decimalPoint);
+ }
+ if (value.length() == 0) {
+ return defaultValue;
+ }
+ try {
+ return Long.parseLong(value);
+ } catch (NumberFormatException e) {
+ return defaultValue;
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ public static Map getRecord(Map record, String recordName) {
+ return (Map) record.get(recordName);
+ }
+
+ public static double getDouble(Map record, String fieldName) {
+ return getDouble(record, fieldName, 0);
+ }
+
+ public static double getDouble(Map record, String fieldName, double defaultValue) {
+ return getDouble(record.get(fieldName), defaultValue);
+ }
+
+ public static double getDouble(Object value, double defaultValue) {
+ return (value instanceof Number) ? ((Number) value).doubleValue() : parseDouble((String) value, defaultValue);
+ }
+
+ public static long getLong(Map record, String fieldName) {
+ return getLong(record, fieldName, 0);
+ }
+
+ public static long getLong(Map record, String fieldName, long defaultValue) {
+ return getLong(record.get(fieldName), defaultValue);
+ }
+
+ public static long getLong(Object value, long defaultValue) {
+ return (value instanceof Number) ? ((Number) value).intValue() : parseDecimalInt((String) value, defaultValue);
+ }
+
+ public static int getInt(Map record, String fieldName) {
+ return getInt(record, fieldName, 0);
+ }
+
+ public static int getInt(Map record, String fieldName, int defaultValue) {
+ return getInt(record.get(fieldName), defaultValue);
+ }
+
+ public static int getInt(Object value, int defaultValue) {
+ return (value instanceof Number) ? ((Number) value).intValue() : (int) parseDecimalInt((String) value, defaultValue);
+ }
+
+ public static int getInt(Object value) {
+ return getInt(value, 0);
+ }
+
+ public static String getString(Map record, String fieldName) {
+ return getString(record.get(fieldName), null);
+ }
+
+ public static String getString(Map record, String fieldName, String defaultValue) {
+ return getString(record.get(fieldName), defaultValue);
+ }
+
+ @NonNull
+ public static String getStringOrEmpty(Map record, String fieldName) {
+ return getStringOrEmpty(record.get(fieldName));
+ }
+
+ @NonNull
+ public static String getStringOrEmpty(Object value) {
+ return getString(value, "");
+ }
+
+ public static String getString(Object value, String defaultValue) {
+ if (value == null) return defaultValue;
+ return (value instanceof String) ? (String) value : value.toString();
+ }
+
+ public static String[] getStringArray(Map record, String fieldName) {
+ return getStringArray((Object[]) record.get(fieldName));
+ }
+
+ private static String[] getStringArray(Object[] objects) {
+ String[] result = new String[objects == null ? 0 : objects.length];
+ if (objects != null) {
+ for (int i = 0; i < objects.length; i++) {
+ result[i] = getString(objects[i], null);
+ }
+ }
+ return result;
+ }
+
+ public static Map mapify(String[] tokens) {
+ Map tokenMap = new HashMap<>();
+ for (String token : tokens) {
+ String[] split = mColonSplitPattern.split(token, 2);
+ tokenMap.put(split[0], split.length > 1 ? split[1] : null);
+ }
+ return tokenMap;
+ }
+
+ /**
+ * Make sure the icon/image tag is an absolute URL.
+ */
+ private static final Pattern HEX_PATTERN = Pattern.compile("^\\p{XDigit}+$");
+
+ @NonNull
+ public static Uri getImageUrl(String urlPrefix, String imageId) {
+ if (imageId != null) {
+ if (HEX_PATTERN.matcher(imageId).matches()) {
+ // if the iconId is a hex digit, this is a coverid or remote track id(a negative id)
+ imageId = "/music/" + imageId + "/cover";
+ }
+
+ // Make sure the url is absolute
+ if (!Uri.parse(imageId).isAbsolute()) {
+ imageId = urlPrefix + (imageId.startsWith("/") ? imageId : "/" + imageId);
+ }
+ }
+ return Uri.parse(imageId != null ? imageId : "");
+ }
+
+ @NonNull
+ public static Uri getImageUrl(Map record, String fieldName) {
+ return getImageUrl(getString(record, "urlPrefix"), getString(record, fieldName));
+ }
+
+ /**
+ * Make sure the icon/image tag is an absolute URL.
+ */
+ @NonNull
+ public static Uri getDownloadUrl(String urlPrefix, String trackId) {
+ return Uri.parse(urlPrefix + "/music/" + trackId + "/download");
+ }
+
+ private static final StringBuilder sFormatBuilder = new StringBuilder();
+
+ private static final Formatter sFormatter = new Formatter(sFormatBuilder, Locale.getDefault());
+
+ private static final Object[] sTimeArgs = new Object[5];
+
+ /**
+ * Formats an elapsed time in the form "M:SS" or "H:MM:SS" for display.
+ *
+ * Like {@link android.text.format.DateUtils#formatElapsedTime(long)} but without the leading
+ * zeroes if the number of minutes is < 10.
+ *
+ * @param elapsedSeconds the elapsed time, in seconds.
+ */
+ public synchronized static String formatElapsedTime(long elapsedSeconds) {
+ calculateTimeArgs(elapsedSeconds);
+ sFormatBuilder.setLength(0);
+ return sFormatter.format("%2$d:%5$02d", sTimeArgs).toString();
+ }
+
+ private static void calculateTimeArgs(long elapsedSeconds) {
+ sTimeArgs[0] = elapsedSeconds / 3600;
+ sTimeArgs[1] = elapsedSeconds / 60;
+ sTimeArgs[2] = (elapsedSeconds / 60) % 60;
+ sTimeArgs[3] = elapsedSeconds;
+ sTimeArgs[4] = elapsedSeconds % 60;
+ }
+
+ /**
+ * Returns {@code true} if the arguments are equal to each other
+ * and {@code false} otherwise.
+ * Consequently, if both arguments are {@code null}, {@code true}
+ * is returned and if exactly one argument is {@code null}, {@code
+ * false} is returned. Otherwise, equality is determined by using
+ * the {@link Object#equals equals} method of the first
+ * argument.
+ */
+ public static boolean equals(Object a, Object b) {
+ return (a == b) || (a != null && a.equals(b));
+ }
+
+ /**
+ * @return a view suitable for use as a spinner view.
+ */
+ public static View getSpinnerItemView(Context context, View convertView, ViewGroup parent,
+ String label) {
+ return getSpinnerView(context, convertView, parent, label,
+ android.R.layout.simple_spinner_item);
+ }
+
+ public static View getActionBarSpinnerItemView(Context context, View convertView,
+ ViewGroup parent, String label) {
+ return getSpinnerView(context, convertView, parent, label,
+ androidx.appcompat.R.layout.support_simple_spinner_dropdown_item);
+ }
+
+ private static View getSpinnerView(Context context, View convertView, ViewGroup parent,
+ String label, int layout) {
+ TextView view;
+ view = (TextView) (convertView != null
+ && TextView.class.isAssignableFrom(convertView.getClass())
+ ? convertView
+ : ((LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE)).inflate(
+ layout, parent, false));
+ view.setText(label);
+ return view;
+ }
+
+ @NonNull
+ public static String getBaseName(String fileName) {
+ String name = new File(fileName).getName();
+ int pos = name.lastIndexOf(".");
+ return (pos > 0) ? name.substring(0, pos) : name;
+ }
+
+ public static void moveFile(ContentResolver resolver, Uri source, Uri destination) throws IOException {
+ try (InputStream inputStream = resolver.openInputStream(source);
+ OutputStream outputStream = resolver.openOutputStream(destination)) {
+ if (inputStream == null) {
+ throw new IOException("moveFile: could not open '" + source + "'");
+ }
+ if (outputStream == null) {
+ throw new IOException("moveFile: could not open '" + destination + "'");
+ }
+ byte[] b = new byte[16384];
+ int bytes;
+ while ((bytes = inputStream.read(b)) > 0) {
+ outputStream.write(b, 0, bytes);
+ }
+ }
+ int deleted = resolver.delete(source, null, null);
+ if (deleted != 1) {
+ throw new IOException("moveFile: try to delete '" + source + "' after copy, expected 1 deleted file but was " + deleted);
+ }
+
+ }
+
+ public static Bitmap vectorToBitmap(Context context, @DrawableRes int vectorResource) {
+ return drawableToBitmap(AppCompatResources.getDrawable(context, vectorResource));
+ }
+
+ public static Bitmap vectorToBitmap(Context context, @DrawableRes int vectorResource, int alpha) {
+ Drawable drawable = AppCompatResources.getDrawable(context, vectorResource);
+ drawable.setAlpha(alpha);
+ return drawableToBitmap(drawable);
+ }
+
+ public static Bitmap drawableToBitmap(Drawable drawable) {
+ if (drawable instanceof BitmapDrawable) {
+ BitmapDrawable bitmapDrawable = (BitmapDrawable) drawable;
+ if(bitmapDrawable.getBitmap() != null) {
+ return bitmapDrawable.getBitmap();
+ }
+ }
+
+ return drawable.getIntrinsicWidth() <= 0 || drawable.getIntrinsicHeight() <= 0
+ ? getBitmap(drawable, 1, 1) // Single color bitmap will be created of 1x1 pixel
+ : getBitmap(drawable, drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight());
+
+ }
+
+ private static Bitmap getBitmap(Drawable drawable, int width, int height) {
+ Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+ Canvas canvas = new Canvas(bitmap);
+ drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
+ drawable.draw(canvas);
+ return bitmap;
+ }
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/VolumePanel.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/VolumePanel.java
new file mode 100644
index 000000000..db8b6b6cd
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/VolumePanel.java
@@ -0,0 +1,209 @@
+/*
+ * Copyright (c) 2011 Google Inc. 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 uk.org.ngo.squeezer;
+
+import android.annotation.SuppressLint;
+import android.app.Dialog;
+import android.content.Context;
+import android.graphics.drawable.GradientDrawable;
+import android.os.Handler;
+import android.os.Message;
+import android.view.Gravity;
+import android.view.LayoutInflater;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.Window;
+import android.view.WindowManager;
+import android.widget.TextView;
+
+import androidx.core.content.ContextCompat;
+
+import com.sdsmdg.harjot.crollerTest.Croller;
+import com.sdsmdg.harjot.crollerTest.OnCrollerChangeListener;
+
+import uk.org.ngo.squeezer.framework.BaseActivity;
+import uk.org.ngo.squeezer.service.ISqueezeService;
+
+
+/**
+ * Implement a custom volume toast view
+ */
+public class VolumePanel extends Handler implements OnCrollerChangeListener {
+
+ private static final int TIMEOUT_DELAY = 3000;
+
+ private static final int MSG_VOLUME_CHANGED = 0;
+
+ private static final int MSG_TIMEOUT = 2;
+
+ private final BaseActivity mActivity;
+
+ /**
+ * Dialog displaying the volume panel.
+ */
+ private final Dialog mDialog;
+
+ /**
+ * View displaying volume sliders.
+ */
+ private final View mView;
+
+ private final TextView mMessage;
+ private final TextView mLabel;
+
+ private final Croller mSeekbar;
+ private int mCurrentProgress = 0;
+ private boolean mTrackingTouch = false;
+
+ @SuppressLint({"InflateParams"}) // OK, as view is passed to Dialog.setView()
+ public VolumePanel(BaseActivity activity) {
+ mActivity = activity;
+
+ LayoutInflater inflater = (LayoutInflater) activity
+ .getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ mView = inflater.inflate(R.layout.volume_adjust, null);
+ GradientDrawable background = (GradientDrawable) ContextCompat.getDrawable(mActivity, R.drawable.panel_background);
+ background.setColor(activity.getResources().getColor(activity.getAttributeValue(R.attr.colorSurface)));
+ background.setStroke(
+ activity.getResources().getDimensionPixelSize(R.dimen.volume_panel_border_Width),
+ activity.getResources().getColor(activity.getAttributeValue(R.attr.colorPrimary))
+ );
+ mView.setBackground(background);
+ mView.setOnTouchListener(new View.OnTouchListener() {
+ @Override
+ public boolean onTouch(View v, MotionEvent event) {
+ resetTimeout();
+ return false;
+ }
+ });
+
+ mMessage = mView.findViewById(R.id.message);
+ mLabel = mView.findViewById(R.id.label);
+ mSeekbar = mView.findViewById(R.id.level);
+
+ mSeekbar.setOnCrollerChangeListener(this);
+
+ mDialog = new Dialog(mActivity, R.style.VolumePanel) { //android.R.style.Theme_Panel) {
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ if (isShowing() && event.getAction() == MotionEvent.ACTION_OUTSIDE) {
+ forceTimeout();
+ return true;
+ }
+ return false;
+ }
+ };
+ mDialog.setTitle("Volume Control");
+ mDialog.setContentView(mView);
+
+ // Set window properties to match other toasts/dialogs.
+ Window window = mDialog.getWindow();
+ window.setGravity(Gravity.TOP);
+ WindowManager.LayoutParams lp = window.getAttributes();
+ lp.token = null;
+ lp.y = activity.getResources().getDimensionPixelSize(R.dimen.volume_panel_top_margin);
+ lp.width = WindowManager.LayoutParams.WRAP_CONTENT;
+ lp.height = WindowManager.LayoutParams.WRAP_CONTENT;
+ window.setAttributes(lp);
+ window.addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
+ | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
+ | WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH);
+ }
+
+ public void dismiss() {
+ removeMessages(MSG_TIMEOUT);
+ if (mDialog.isShowing()) {
+ mDialog.dismiss();
+ }
+ }
+
+ private void resetTimeout() {
+ removeMessages(MSG_TIMEOUT);
+ sendMessageDelayed(obtainMessage(MSG_TIMEOUT), TIMEOUT_DELAY);
+ }
+
+ private void forceTimeout() {
+ removeMessages(MSG_TIMEOUT);
+ sendMessage(obtainMessage(MSG_TIMEOUT));
+ }
+
+ @Override
+ public void onProgressChanged(Croller croller, int progress) {
+ if (mCurrentProgress != progress) {
+ mCurrentProgress = progress;
+ ISqueezeService service = mActivity.getService();
+ if (service != null) {
+ service.adjustVolumeTo(progress);
+ }
+ }
+ }
+
+ @Override
+ public void onStartTrackingTouch(Croller croller) {
+ mTrackingTouch = true;
+ removeMessages(MSG_TIMEOUT);
+ }
+
+ @Override
+ public void onStopTrackingTouch(Croller croller) {
+ mTrackingTouch = false;
+ resetTimeout();
+ }
+
+ public void postVolumeChanged(int newVolume, String additionalMessage) {
+ if (hasMessages(MSG_VOLUME_CHANGED)) {
+ return;
+ }
+ obtainMessage(MSG_VOLUME_CHANGED, newVolume, 0, additionalMessage).sendToTarget();
+ }
+
+ private void onShowVolumeChanged(int newVolume, String additionalMessage) {
+ if (mTrackingTouch) {
+ return;
+ }
+
+ mCurrentProgress = newVolume;
+ mSeekbar.setProgress(newVolume);
+ mMessage.setText(mActivity.getString(R.string.volume, mActivity.getString(R.string.app_name)));
+ mLabel.setText(additionalMessage);
+
+ if (!mDialog.isShowing() && !mActivity.isFinishing()) {
+ mDialog.setContentView(mView);
+ mDialog.show();
+ }
+
+ resetTimeout();
+ }
+
+ @Override
+ public void handleMessage(Message msg) {
+ switch (msg.what) {
+
+ case MSG_VOLUME_CHANGED: {
+ onShowVolumeChanged(msg.arg1, (String) msg.obj);
+ break;
+ }
+
+ case MSG_TIMEOUT: {
+ dismiss();
+ break;
+ }
+ }
+ }
+}
+
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/AboutDialog.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/AboutDialog.java
new file mode 100644
index 000000000..c68bc5a4f
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/AboutDialog.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright (c) 2012 Google Inc. 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 uk.org.ngo.squeezer.dialog;
+
+import android.annotation.SuppressLint;
+import android.app.Dialog;
+import android.content.DialogInterface;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.os.Bundle;
+import androidx.annotation.NonNull;
+import androidx.fragment.app.DialogFragment;
+import android.view.View;
+import android.widget.TextView;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import uk.org.ngo.squeezer.BuildConfig;
+import uk.org.ngo.squeezer.R;
+
+public class AboutDialog extends DialogFragment {
+ @NonNull
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ @SuppressLint({"InflateParams"})
+ final View view = getActivity().getLayoutInflater().inflate(R.layout.about_dialog, null);
+ final TextView titleText = view.findViewById(R.id.about_title);
+ final TextView versionText = view.findViewById(R.id.version_text);
+
+ PackageManager pm = getActivity().getPackageManager();
+ PackageInfo info;
+ try {
+ info = pm.getPackageInfo(getActivity().getPackageName(), 0);
+ if (BuildConfig.DEBUG) {
+ versionText.setText(info.versionName + ' ' + BuildConfig.GIT_DESCRIPTION);
+ } else {
+ versionText.setText(info.versionName);
+ }
+ } catch (NameNotFoundException e) {
+ titleText.setText(getString(R.string.app_name));
+ }
+
+ MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(getActivity());
+ builder.setView(view);
+ builder.setPositiveButton(android.R.string.ok, null);
+ builder.setNeutralButton(R.string.changelog_full_title, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ ChangeLogDialog changeLog = new ChangeLogDialog(getActivity());
+ changeLog.getThemedFullLogDialog().show();
+ }
+ });
+ builder.setNegativeButton(R.string.dialog_license, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ new LicenseDialog()
+ .show(getActivity().getSupportFragmentManager(), "LicenseDialog");
+ }
+ });
+ return builder.create();
+ }
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/AlertEventDialog.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/AlertEventDialog.java
new file mode 100644
index 000000000..972af4fff
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/AlertEventDialog.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright (c) 2019 Kurt Aaholst
+ *
+ * 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 uk.org.ngo.squeezer.dialog;
+
+import android.app.Dialog;
+import android.os.Bundle;
+import androidx.annotation.NonNull;
+import androidx.fragment.app.DialogFragment;
+import androidx.fragment.app.FragmentManager;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+public class AlertEventDialog extends DialogFragment {
+ private static final String TAG = AlertEventDialog.class.getSimpleName();
+ private static final String TITLE_KEY = "TITLE_KEY";
+ private static final String MESSAGE_KEY = "MESSAGE_KEY";
+
+ @NonNull
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ return new MaterialAlertDialogBuilder(getActivity())
+ .setTitle(getArguments().getString(TITLE_KEY))
+ .setMessage(getArguments().getString(MESSAGE_KEY))
+ .setPositiveButton(android.R.string.ok, null)
+ .create();
+ }
+
+ public static AlertEventDialog show(FragmentManager fragmentManager, String title, String text) {
+ AlertEventDialog dialog = new AlertEventDialog();
+
+ Bundle args = new Bundle();
+ args.putString(TITLE_KEY, title);
+ args.putString(MESSAGE_KEY, text);
+ dialog.setArguments(args);
+
+ dialog.show(fragmentManager, TAG);
+ return dialog;
+ }
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/ChangeLogDialog.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/ChangeLogDialog.java
new file mode 100644
index 000000000..d9b549da3
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/ChangeLogDialog.java
@@ -0,0 +1,81 @@
+package uk.org.ngo.squeezer.dialog;
+
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.SharedPreferences;
+import androidx.appcompat.app.AlertDialog;
+import android.webkit.WebView;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import uk.org.ngo.squeezer.R;
+
+/**
+ * Extends ChangeLog to use the v7 support AlertDialog which follows the application theme.
+ */
+public class ChangeLogDialog extends de.cketti.library.changelog.ChangeLog {
+ public ChangeLogDialog(final Context context) {
+ super(context);
+ }
+
+ public ChangeLogDialog(final Context context, final String css) {
+ super(context, css);
+ }
+
+ public ChangeLogDialog(final Context context, final SharedPreferences preferences, final String css) {
+ super(context, preferences, css);
+ }
+
+ /**
+ * Get a themed "What's New" dialog.
+ *
+ * @return An AlertDialog displaying the changes since the previous installed version of your
+ * app (What's New). But when this is the first run of your app including
+ * {@code ChangeLog} then the full log dialog is show.
+ */
+ public AlertDialog getThemedLogDialog() {
+ return getThemedDialog(isFirstRunEver());
+ }
+
+ /**
+ * Get a themed dialog with the full change log.
+ *
+ * @return An AlertDialog with a full change log displayed.
+ */
+ public AlertDialog getThemedFullLogDialog() {
+ return getThemedDialog(true);
+ }
+
+ private AlertDialog getThemedDialog(boolean full) {
+ WebView wv = new WebView(mContext.getApplicationContext());
+ wv.loadDataWithBaseURL(null, getLog(full), "text/html", "UTF-8", null);
+
+ MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(mContext);
+ builder.setView(wv)
+ .setCancelable(false)
+ // OK button
+ .setPositiveButton(
+ mContext.getResources().getString(R.string.changelog_ok_button),
+ new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ // The user clicked "OK" so save the current version code as
+ // "last version code".
+ updateVersionInPreferences();
+ }
+ });
+
+ if (!full) {
+ // Show "More..." button if we're only displaying a partial change log.
+ builder.setNegativeButton(R.string.changelog_show_full,
+ new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int id) {
+ getThemedFullLogDialog().show();
+ }
+ });
+ }
+
+ return builder.create();
+ }
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/DownloadDialog.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/DownloadDialog.java
new file mode 100644
index 000000000..8aab22871
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/DownloadDialog.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright (c) 2019 Kurt Aaholst
+ *
+ * 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 uk.org.ngo.squeezer.dialog;
+
+import android.content.DialogInterface;
+import android.os.Bundle;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.DialogFragment;
+import androidx.fragment.app.FragmentManager;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import uk.org.ngo.squeezer.R;
+import uk.org.ngo.squeezer.model.JiveItem;
+
+public class DownloadDialog extends DialogFragment {
+ private static final String TAG = DownloadDialog.class.getSimpleName();
+ private static final String TITLE_KEY = "TITLE_KEY";
+ private DownloadDialogListener callback;
+
+ public DownloadDialog(DownloadDialogListener callback) {
+ this.callback = callback;
+ }
+
+ @NonNull
+ @Override
+ public AlertDialog onCreateDialog(Bundle savedInstanceState) {
+ return new MaterialAlertDialogBuilder(getActivity())
+ .setTitle(getString(R.string.download_item, getArguments().getString(TITLE_KEY)))
+ .setMultiChoiceItems(new String[]{getString(R.string.DONT_ASK_AGAIN)}, new boolean[]{false}, (dialogInterface, i, b) -> setNegativeButtonText(b))
+ .setPositiveButton(R.string.DOWNLOAD, (dialogInterface, i) -> callback.download(isPersistChecked()))
+ .setNegativeButton(android.R.string.cancel, (dialogInterface, i) -> callback.cancel(isPersistChecked()))
+ .create();
+ }
+
+ private void setNegativeButtonText(boolean b) {
+ getDialog().getButton(DialogInterface.BUTTON_NEGATIVE).setText(b ? R.string.disable_downloads : android.R.string.cancel);
+ }
+
+ private boolean isPersistChecked() {
+ return getDialog().getListView().isItemChecked(0);
+ }
+
+ @Override
+ public AlertDialog getDialog() {
+ return (AlertDialog) super.getDialog();
+ }
+
+ public static DownloadDialog show(FragmentManager fragmentManager, JiveItem item, DownloadDialogListener callback) {
+ DownloadDialog dialog = new DownloadDialog(callback);
+
+ Bundle args = new Bundle();
+ args.putString(TITLE_KEY, item.getName());
+ dialog.setArguments(args);
+
+ dialog.show(fragmentManager, TAG);
+ return dialog;
+ }
+
+ public interface DownloadDialogListener {
+ void download(boolean persist);
+ void cancel(boolean persist);
+ }
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/EnableWifiDialog.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/EnableWifiDialog.java
new file mode 100644
index 000000000..9fbdd2381
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/EnableWifiDialog.java
@@ -0,0 +1,44 @@
+package uk.org.ngo.squeezer.dialog;
+
+import android.app.Dialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.net.wifi.WifiManager;
+import android.os.Bundle;
+import androidx.annotation.NonNull;
+import androidx.fragment.app.DialogFragment;
+import android.util.Log;
+import android.widget.Toast;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import uk.org.ngo.squeezer.R;
+
+public class EnableWifiDialog extends DialogFragment {
+
+ public static final String TAG = EnableWifiDialog.class.getSimpleName();
+
+ @NonNull
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(getActivity());
+ builder.setTitle(R.string.wifi_disabled_text);
+ builder.setMessage(R.string.enable_wifi_text);
+ builder.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ WifiManager wifiManager = (WifiManager) getActivity()
+ .getApplicationContext().getSystemService(
+ Context.WIFI_SERVICE);
+ if (!wifiManager.isWifiEnabled()) {
+ Log.v(TAG, "Enabling Wifi");
+ wifiManager.setWifiEnabled(true);
+ Toast.makeText(getActivity(), R.string.wifi_enabled_text, Toast.LENGTH_LONG)
+ .show();
+ }
+ }
+ });
+ builder.setNegativeButton(android.R.string.cancel, null);
+ return builder.create();
+ }
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/LicenseDialog.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/LicenseDialog.java
new file mode 100644
index 000000000..86be570a3
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/LicenseDialog.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2012 Google Inc. 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 uk.org.ngo.squeezer.dialog;
+
+import android.app.Dialog;
+import android.os.Bundle;
+import androidx.annotation.NonNull;
+import androidx.fragment.app.DialogFragment;
+import android.text.Html;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import uk.org.ngo.squeezer.R;
+
+public class LicenseDialog extends DialogFragment {
+
+ @NonNull
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ return new MaterialAlertDialogBuilder(getActivity())
+ .setMessage(Html.fromHtml((String) getText(R.string.license_text)))
+ .setPositiveButton(android.R.string.ok, null)
+ .create();
+ }
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/NetworkErrorDialogFragment.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/NetworkErrorDialogFragment.java
new file mode 100644
index 000000000..1a8817a50
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/NetworkErrorDialogFragment.java
@@ -0,0 +1,107 @@
+/*
+ * Copyright (c) 2014 Google Inc. 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 uk.org.ngo.squeezer.dialog;
+
+import android.app.Activity;
+import android.app.Dialog;
+import android.content.DialogInterface;
+import android.os.Bundle;
+import androidx.annotation.NonNull;
+import androidx.fragment.app.DialogFragment;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+/**
+ * A dialog for displaying networking error messages received from the server.
+ *
+ * Activities that host this dialog must implement
+ * {@link NetworkErrorDialogFragment.NetworkErrorDialogListener} to
+ * be notified when the user dismisses the dialog.
+ *
+ * To easily create the dialog displaying a given message call {@link #newInstance(String)} with
+ * the message to display.
+ */
+public class NetworkErrorDialogFragment extends DialogFragment {
+ /** Key used to store the message in the arguments bundle. */
+ private static final String MESSAGE_KEY = "message";
+
+ /** The activity that hosts this dialog. */
+ private NetworkErrorDialogListener mListener;
+
+ /**
+ * Activities hosting this dialog must implement this interface in order to receive
+ * notifications when the user dismisses the dialog.
+ */
+ public interface NetworkErrorDialogListener {
+
+ /**
+ * The user has dismissed the dialog. Either by clicking the OK button, or by pressing
+ * the "Back" button.
+ *
+ * @param dialog The dialog that has been dismissed.
+ */
+ void onDialogDismissed(DialogInterface dialog);
+ }
+
+ /**
+ * Static factory method for creating an instance that will display the given message.
+ *
+ * @param message The message to display in the dialog.
+ * @return The created dialog fragment.
+ */
+ @NonNull
+ public static NetworkErrorDialogFragment newInstance(@NonNull String message) {
+ NetworkErrorDialogFragment fragment = new NetworkErrorDialogFragment();
+
+ Bundle args = new Bundle();
+ args.putString(MESSAGE_KEY, message);
+ fragment.setArguments(args);
+
+ return fragment;
+ }
+
+ // Ensure that the containing activity implements NetworkErrorDialogListener.
+ @Override
+ public void onAttach(Activity activity) {
+ super.onAttach(activity);
+
+ try {
+ mListener = (NetworkErrorDialogListener) activity;
+ } catch (ClassCastException e) {
+ throw new ClassCastException(activity + " must implement NetworkErrorDialogListener");
+ }
+ }
+
+ @Override
+ @NonNull
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ String message = getArguments().getString(MESSAGE_KEY);
+ if (message == null) {
+ message = "No message provided.";
+ }
+
+ return new MaterialAlertDialogBuilder(getActivity())
+ .setMessage(message).setPositiveButton(android.R.string.ok, null)
+ .create();
+ }
+
+ @Override
+ public void onDismiss(DialogInterface dialog) {
+ super.onDismiss(dialog);
+ mListener.onDialogDismissed(dialog);
+ }
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/ServerAddressView.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/ServerAddressView.java
new file mode 100644
index 000000000..b9be742b9
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/ServerAddressView.java
@@ -0,0 +1,279 @@
+/*
+ * Copyright (c) 2012 Google Inc. 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 uk.org.ngo.squeezer.dialog;
+
+import android.content.Context;
+import android.net.ConnectivityManager;
+import android.net.NetworkInfo;
+import android.util.AttributeSet;
+import android.view.View;
+import android.widget.AdapterView;
+import android.widget.AdapterView.OnItemSelectedListener;
+import android.widget.ArrayAdapter;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+import android.widget.RadioButton;
+import android.widget.Spinner;
+import android.widget.TextView;
+
+import java.util.Map.Entry;
+import java.util.TreeMap;
+
+import uk.org.ngo.squeezer.Preferences;
+import uk.org.ngo.squeezer.R;
+import uk.org.ngo.squeezer.util.ScanNetworkTask;
+
+/**
+ * Scans the local network for servers, allow the user to choose one, set it as the preferred server
+ * for this network, and optionally enter authentication information.
+ *
+ * A new network scan can be initiated manually if desired.
+ */
+public class ServerAddressView extends LinearLayout implements ScanNetworkTask.ScanNetworkCallback {
+ private Preferences mPreferences;
+ private Preferences.ServerAddress mServerAddress;
+
+ private RadioButton mSqueezeNetworkButton;
+ private RadioButton mLocalServerButton;
+ private EditText mServerAddressEditText;
+ private TextView mServerName;
+ private Spinner mServersSpinner;
+ private EditText mUserNameEditText;
+ private EditText mPasswordEditText;
+ private View mScanResults;
+ private View mScanProgress;
+
+ private ScanNetworkTask mScanNetworkTask;
+
+ /** Map server names to IP addresses. */
+ private TreeMap mDiscoveredServers;
+
+ private ArrayAdapter mServersAdapter;
+
+ public ServerAddressView(final Context context) {
+ super(context);
+ initialize(context);
+ }
+
+ public ServerAddressView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ initialize(context);
+ }
+
+ private void initialize(final Context context) {
+ inflate(context, R.layout.server_address_view, this);
+ if (!isInEditMode()) {
+ mPreferences = new Preferences(context);
+ mServerAddress = mPreferences.getServerAddress();
+ if (mServerAddress.localAddress() == null) {
+ Preferences.ServerAddress cliServerAddress = mPreferences.getCliServerAddress();
+ if (cliServerAddress.localAddress() != null) {
+ mServerAddress.setAddress(cliServerAddress.localHost());
+ }
+ }
+
+ mSqueezeNetworkButton = findViewById(R.id.squeezeNetwork);
+ mLocalServerButton = findViewById(R.id.squeezeServer);
+
+ mServerAddressEditText = findViewById(R.id.server_address);
+ mUserNameEditText = findViewById(R.id.username);
+ mPasswordEditText = findViewById(R.id.password);
+
+ final OnClickListener onNetworkSelected = view -> setSqueezeNetwork(view.getId() == R.id.squeezeNetwork);
+ mSqueezeNetworkButton.setOnClickListener(onNetworkSelected);
+ mLocalServerButton.setOnClickListener(onNetworkSelected);
+
+ // Set up the servers spinner.
+ mServersAdapter = new ArrayAdapter<>(context, android.R.layout.simple_spinner_item);
+ mServersAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+ mServerName = findViewById(R.id.server_name);
+ mServersSpinner = findViewById(R.id.found_servers);
+ mServersSpinner.setAdapter(mServersAdapter);
+
+ mScanResults = findViewById(R.id.scan_results);
+ mScanProgress = findViewById(R.id.scan_progress);
+ mScanProgress.setVisibility(GONE);
+ TextView scanDisabledMessage = findViewById(R.id.scan_disabled_msg);
+
+ setSqueezeNetwork(mServerAddress.squeezeNetwork);
+ setServerAddress(mServerAddress.localAddress());
+
+ // Only support network scanning on WiFi.
+ ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
+ NetworkInfo ni = connectivityManager.getActiveNetworkInfo();
+ boolean isWifi = ni != null && ni.getType() == ConnectivityManager.TYPE_WIFI;
+ if (isWifi) {
+ scanDisabledMessage.setVisibility(GONE);
+ startNetworkScan(context);
+ Button scanButton = findViewById(R.id.scan_button);
+ scanButton.setOnClickListener(v -> startNetworkScan(context));
+ } else {
+ mScanResults.setVisibility(GONE);
+ }
+ }
+ }
+
+ public void savePreferences() {
+ mServerAddress.squeezeNetwork = mSqueezeNetworkButton.isChecked();
+ String address = mServerAddressEditText.getText().toString();
+ mServerAddress.setAddress(address);
+ mPreferences.saveServerAddress(mServerAddress);
+
+ mPreferences.saveServerName(mServerAddress, getServerName(address));
+
+ String username = mUserNameEditText.getText().toString();
+ String password = mPasswordEditText.getText().toString();
+ mPreferences.saveUserCredentials(mServerAddress, username, password);
+ }
+
+ @Override
+ protected void onDetachedFromWindow() {
+ // Stop scanning
+ if (mScanNetworkTask != null) {
+ mScanNetworkTask.cancel();
+ }
+
+ super.onDetachedFromWindow();
+ }
+
+ /**
+ * Starts scanning for servers.
+ */
+ void startNetworkScan(Context context) {
+ mScanResults.setVisibility(GONE);
+ mScanProgress.setVisibility(VISIBLE);
+ mScanNetworkTask = new ScanNetworkTask(context, this);
+ new Thread(mScanNetworkTask).start();
+ }
+
+ /**
+ * Called when server scanning has finished.
+ * @param serverMap Discovered servers, key is the server name, value is the IP address.
+ */
+ public void onScanFinished(TreeMap serverMap) {
+ mScanResults.setVisibility(VISIBLE);
+ mServerName.setVisibility(GONE);
+ mServersSpinner.setVisibility(GONE);
+ mScanProgress.setVisibility(GONE);
+ mServersAdapter.clear();
+
+ if (mScanNetworkTask == null) {
+ return;
+ }
+
+ mDiscoveredServers = serverMap;
+
+ mScanNetworkTask = null;
+
+ if (mDiscoveredServers.size() == 0) {
+ // No servers found, manually enter address
+ // Populate the edit text widget with current address stored in preferences.
+ setServerAddress(mServerAddress.localAddress());
+ mServerAddressEditText.setEnabled(true);
+ mServerName.setVisibility(VISIBLE);
+ } else {
+ // Show the spinner so the user can choose a server or to manually enter address.
+ // Don't fire onItemSelected by calling notifyDataSetChanged and
+ // setSelection(pos, false) before setting OnItemSelectedListener
+ mServersSpinner.setOnItemSelectedListener(null);
+
+ for (Entry e : mDiscoveredServers.entrySet()) {
+ mServersAdapter.add(e.getKey());
+ }
+ mServersAdapter.add(getContext().getString(R.string.settings_manual_server_addr));
+ mServersAdapter.notifyDataSetChanged();
+
+ // First look the stored server name in the list of found servers
+ String addressOfStoredServerName = mDiscoveredServers.get(mPreferences.getServerName(mServerAddress));
+ int position = getServerPosition(addressOfStoredServerName);
+
+ // If that fails, look for the stored server address in the list of found servers
+ if (position < 0) {
+ position = getServerPosition(mServerAddress.localAddress());
+ }
+
+ mServersSpinner.setSelection((position < 0 ? mServersAdapter.getCount() - 1 : position), false);
+ mServerAddressEditText.setEnabled(position < 0 && !mServerAddress.squeezeNetwork);
+
+ mServersSpinner.setOnItemSelectedListener(new MyOnItemSelectedListener());
+ mServersSpinner.setVisibility(VISIBLE);
+ }
+ }
+
+ private void setSqueezeNetwork(boolean isSqueezeNetwork) {
+ mSqueezeNetworkButton.setChecked(isSqueezeNetwork);
+ mLocalServerButton.setChecked(!isSqueezeNetwork);
+ setEditServerAddressAvailability(isSqueezeNetwork);
+ mUserNameEditText.setEnabled(!isSqueezeNetwork);
+ mPasswordEditText.setEnabled(!isSqueezeNetwork);
+ }
+
+ private void setServerAddress(String address) {
+ mServerAddress.setAddress(address);
+
+ mServerAddressEditText.setText(mServerAddress.localAddress());
+ mUserNameEditText.setText(mPreferences.getUsername(mServerAddress));
+ mPasswordEditText.setText(mPreferences.getPassword(mServerAddress));
+ }
+
+ private void setEditServerAddressAvailability(boolean isSqueezeNetwork) {
+ if (isSqueezeNetwork) {
+ mServerAddressEditText.setEnabled(false);
+ } else if (mServersAdapter.getCount() == 0) {
+ mServerAddressEditText.setEnabled(true);
+ } else {
+ mServerAddressEditText.setEnabled(mServersSpinner.getSelectedItemPosition() == mServersSpinner.getCount() - 1);
+ }
+ }
+
+ private String getServerName(String ipPort) {
+ if (mDiscoveredServers != null)
+ for (Entry entry : mDiscoveredServers.entrySet())
+ if (ipPort.equals(entry.getValue()))
+ return entry.getKey();
+ return null;
+ }
+
+ private int getServerPosition(String host) {
+ if (host != null && mDiscoveredServers != null) {
+ int position = 0;
+ for (Entry entry : mDiscoveredServers.entrySet()) {
+ if (host.equals(entry.getValue()))
+ return position;
+ position++;
+ }
+ }
+ return -1;
+ }
+
+ /**
+ * Inserts the selected address in to the edit text widget.
+ */
+ private class MyOnItemSelectedListener implements OnItemSelectedListener {
+ public void onItemSelected(AdapterView> parent, View view, int pos, long id) {
+ String serverAddress = mDiscoveredServers.get(mServersAdapter.getItem(pos));
+ setSqueezeNetwork(false);
+ setServerAddress(serverAddress);
+ }
+
+ public void onNothingSelected(AdapterView> parent) {
+ // Do nothing.
+ }
+ }
+
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/TipsDialog.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/TipsDialog.java
new file mode 100644
index 000000000..160c10661
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/TipsDialog.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright (c) 2012 Google Inc. 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 uk.org.ngo.squeezer.dialog;
+
+import android.annotation.SuppressLint;
+import android.app.Dialog;
+import android.content.DialogInterface;
+import android.content.DialogInterface.OnKeyListener;
+import android.os.Bundle;
+import androidx.annotation.NonNull;
+import androidx.fragment.app.DialogFragment;
+import android.view.KeyEvent;
+import android.view.View;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import uk.org.ngo.squeezer.R;
+
+public class TipsDialog extends DialogFragment implements OnKeyListener {
+
+ @NonNull
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ @SuppressLint({"InflateParams"})
+ final View view = getActivity().getLayoutInflater().inflate(R.layout.tips_dialog, null);
+
+ return new MaterialAlertDialogBuilder(getActivity())
+ .setView(view)
+ .setPositiveButton(android.R.string.ok, null)
+ .setOnKeyListener(this)
+ .create();
+ }
+
+ /*
+ * Intercept hardware volume control keys to control Squeezeserver volume.
+ *
+ * Change the volume when the key is depressed. Suppress the keyUp event,
+ * otherwise you get a notification beep as well as the volume changing.
+ *
+ * TODO: Do this for all the dialog.
+ */
+ @Override
+ public boolean onKey(DialogInterface dialog, int keyCode, KeyEvent event) {
+ switch (keyCode) {
+ case KeyEvent.KEYCODE_VOLUME_UP:
+ case KeyEvent.KEYCODE_VOLUME_DOWN:
+ return getActivity().onKeyDown(keyCode, event);
+ }
+
+ return false;
+ }
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/download/CancelDownloadsActivity.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/download/CancelDownloadsActivity.java
new file mode 100644
index 000000000..014f238de
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/download/CancelDownloadsActivity.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright (c) 2014 Kurt Aaholst
+ *
+ * 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 uk.org.ngo.squeezer.download;
+
+import android.app.Activity;
+import android.app.DownloadManager;
+import android.content.Context;
+import android.os.Bundle;
+import android.util.Log;
+
+import uk.org.ngo.squeezer.R;
+import uk.org.ngo.squeezer.util.AsyncTask;
+
+/**
+ * An activity which gives the option, using a dialog theme, to cancel pending
+ * Squeezer downloads.
+ */
+public class CancelDownloadsActivity extends Activity {
+ private static final String TAG = CancelDownloadsActivity.class.getSimpleName();
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.cancel_downloads);
+ findViewById(R.id.cancel_button).setOnClickListener(view -> finish());
+ findViewById(R.id.ok_button).setOnClickListener(view -> {
+ cancelDownloads();
+ finish();
+ });
+ }
+
+ private void cancelDownloads() {
+ Log.i(TAG, "cancelDownloads");
+ new CancelDownloadsTask(this).execute();
+ }
+
+ static class CancelDownloadsTask extends AsyncTask {
+ final DownloadDatabase downloadDatabase;
+ final DownloadManager downloadManager;
+
+ public CancelDownloadsTask(Context context) {
+ downloadDatabase = new DownloadDatabase(context);
+ downloadManager =(DownloadManager)context.getSystemService(Context.DOWNLOAD_SERVICE);
+ }
+
+ @Override
+ protected Void doInBackground(Void... params) {
+ downloadDatabase.iterateDownloadEntries(entry -> {
+ if (entry.downloadId != -1) {
+ downloadManager.remove(entry.downloadId);
+ }
+ downloadDatabase.remove(entry.url);
+ });
+ return null;
+ }
+ }
+
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/download/DownloadDatabase.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/download/DownloadDatabase.java
new file mode 100644
index 000000000..83c018bcc
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/download/DownloadDatabase.java
@@ -0,0 +1,244 @@
+/*
+ * Copyright (c) 2014 Kurt Aaholst
+ *
+ * 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 uk.org.ngo.squeezer.download;
+
+import android.app.DownloadManager;
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteOpenHelper;
+import android.net.Uri;
+import android.util.Base64;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+/**
+ * Maintain a queue of download requests.
+ *
+ * Enqueue a new download request via {@link #enqueueDownload(DownloadManager, String, Uri, String)} and
+ * call {@link #popDownloadEntry(Context, long)} when a download is completed.
+ */
+public class DownloadDatabase {
+ public static final String TAG = DownloadDatabase.class.getSimpleName();
+
+ private static class DOWNLOAD_DATABASE {
+ private static final String NAME = "download";
+ private static final int VERSION = 5;
+
+ private static class SONG {
+ private static final String TABLE = "download";
+
+ private static class COLUMNS {
+ private static final String DOWNLOAD_ID = "download_id";
+ private static final String URL = "url";
+ private static final String FILE_NAME = "file_name";
+ private static final String CREDENTIALS = "credentials";
+ private static final String TITLE = "title";
+ private static final String ALBUM = "album";
+ private static final String ARTIST = "artist";
+ }
+ }
+ }
+
+ private final SQLiteDatabase db;
+
+ public DownloadDatabase(Context context) {
+ db = OpenHelper.getInstance(context).getWritableDatabase();
+ }
+
+ private static class OpenHelper extends SQLiteOpenHelper {
+
+ private static final Object mInstanceLock = new Object();
+ private static OpenHelper mInstance;
+
+ private OpenHelper(Context context) {
+ // calls the super constructor, requesting the default cursor
+ // factory.
+ super(context, DOWNLOAD_DATABASE.NAME, null, DOWNLOAD_DATABASE.VERSION);
+ }
+
+ public static OpenHelper getInstance(Context context) {
+ if (mInstance == null) {
+ synchronized (mInstanceLock) {
+ if (mInstance == null) {
+ mInstance = new OpenHelper(context);
+ }
+ }
+ }
+ return mInstance;
+ }
+
+ @Override
+ public void onCreate(SQLiteDatabase sqLiteDatabase) {
+ sqLiteDatabase.execSQL("CREATE TABLE " + DOWNLOAD_DATABASE.SONG.TABLE + "(" +
+ DOWNLOAD_DATABASE.SONG.COLUMNS.DOWNLOAD_ID + " INTEGER, " +
+ DOWNLOAD_DATABASE.SONG.COLUMNS.URL + " TEXT, " +
+ DOWNLOAD_DATABASE.SONG.COLUMNS.FILE_NAME + " TEXT, " +
+ DOWNLOAD_DATABASE.SONG.COLUMNS.CREDENTIALS + " TEXT, " +
+ DOWNLOAD_DATABASE.SONG.COLUMNS.TITLE + " TEXT, " +
+ DOWNLOAD_DATABASE.SONG.COLUMNS.ALBUM + " TEXT, " +
+ DOWNLOAD_DATABASE.SONG.COLUMNS.ARTIST + " TEXT)");
+ }
+
+ @Override
+ public void onUpgrade(SQLiteDatabase sqLiteDatabase, int oldVersion, int newVersion) {
+ sqLiteDatabase.execSQL("DROP TABLE IF EXISTS " + DOWNLOAD_DATABASE.SONG.TABLE);
+ // Upgrades just creates a new database. The database keeps track of
+ // active downloads, so it holds only temporary information.
+ onCreate(sqLiteDatabase);
+ }
+
+ }
+
+ /**
+ * Register a download request.
+ */
+ public void registerDownload(Context context, String credentials, Uri url, @NonNull String fileName, @NonNull String title, String album, String artist) {
+ // To avoid download manager stops processing our requests due to exceeding the rate
+ // limit for notifications (because download manager shows a notification), we delay
+ // enqueuing further download requests until any current enqueued requests is completed.
+ DownloadManager downloadManager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE);
+ long downloadId = (activeRequests() < 4) ? enqueueDownload(downloadManager, credentials, url, title) : -1;
+
+ ContentValues contentValues = new ContentValues();
+ contentValues.put(DOWNLOAD_DATABASE.SONG.COLUMNS.DOWNLOAD_ID, downloadId);
+ contentValues.put(DOWNLOAD_DATABASE.SONG.COLUMNS.URL, url.toString());
+ contentValues.put(DOWNLOAD_DATABASE.SONG.COLUMNS.FILE_NAME, fileName);
+ contentValues.put(DOWNLOAD_DATABASE.SONG.COLUMNS.CREDENTIALS, credentials);
+ contentValues.put(DOWNLOAD_DATABASE.SONG.COLUMNS.TITLE, title);
+ contentValues.put(DOWNLOAD_DATABASE.SONG.COLUMNS.ALBUM, album);
+ contentValues.put(DOWNLOAD_DATABASE.SONG.COLUMNS.ARTIST, artist);
+ if (db.insert(DOWNLOAD_DATABASE.SONG.TABLE, null, contentValues) == -1) {
+ Log.w(TAG, "Could not register download entry for: " + title);
+ if (downloadId != -1) {
+ downloadManager.remove(downloadId);
+ }
+ }
+ }
+
+ /**
+ * Enqueue a download if any pending
+ */
+ private void maybeEnqueueDownload(Context context) {
+ DownloadEntry entry = null;
+
+ try (Cursor cursor = db.rawQuery("select * from " + DOWNLOAD_DATABASE.SONG.TABLE +
+ " where " + DOWNLOAD_DATABASE.SONG.COLUMNS.DOWNLOAD_ID + "=?", new String[]{String.valueOf(-1)})) {
+ if (cursor.moveToNext()) {
+ entry = getDownloadEntry(cursor);
+ }
+ }
+ if (entry != null) {
+ DownloadManager downloadManager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE);
+ long downloadId = enqueueDownload(downloadManager, entry.credentials, entry.url, entry.title);
+ ContentValues contentValues = new ContentValues();
+ contentValues.put(DOWNLOAD_DATABASE.SONG.COLUMNS.DOWNLOAD_ID, downloadId);
+ db.update(DOWNLOAD_DATABASE.SONG.TABLE, contentValues, DOWNLOAD_DATABASE.SONG.COLUMNS.URL + "=?",
+ new String[]{String.valueOf(entry.url)});
+ }
+ }
+
+ private long activeRequests() {
+ try (Cursor cursor = db.rawQuery("select count(*) from " + DOWNLOAD_DATABASE.SONG.TABLE +
+ " where " + DOWNLOAD_DATABASE.SONG.COLUMNS.DOWNLOAD_ID + " <>?", new String[]{String.valueOf(-1)})) {
+ if (cursor.moveToNext()) {
+ return cursor.getLong(0);
+ }
+ }
+ return 0;
+ }
+
+ private long enqueueDownload(DownloadManager downloadManager, String credentials, Uri url, @NonNull String title) {
+ String base64EncodedCredentials = Base64.encodeToString(credentials.getBytes(), Base64.NO_WRAP);
+ DownloadManager.Request request = new DownloadManager.Request(url)
+ .setTitle(title)
+ .setVisibleInDownloadsUi(false)
+ .addRequestHeader("Authorization", "Basic " + base64EncodedCredentials);
+ long downloadId = downloadManager.enqueue(request);
+ Log.i(TAG, "download enqueued[" + title + "]: " + downloadId);
+ return downloadId;
+ }
+
+ /**
+ * Search for a previously registered download entry with the supplied id.
+ * If an entry is found it is returned, the download is unregistered and if any pending
+ * downloads a new one is enqueued.
+ *
+ * @param downloadId Download id
+ * @return The registered download entry or null if not found
+ */
+ @Nullable
+ public DownloadEntry popDownloadEntry(Context context, long downloadId) {
+ DownloadEntry entry = null;
+
+ try (Cursor cursor = db.rawQuery("select * from " + DOWNLOAD_DATABASE.SONG.TABLE +
+ " where " + DOWNLOAD_DATABASE.SONG.COLUMNS.DOWNLOAD_ID + "=?",
+ new String[]{String.valueOf(downloadId)})) {
+ if (cursor.moveToNext()) {
+ entry = getDownloadEntry(cursor);
+ }
+ }
+ if (entry != null) {
+ db.delete(DOWNLOAD_DATABASE.SONG.TABLE, DOWNLOAD_DATABASE.SONG.COLUMNS.DOWNLOAD_ID + "=?",
+ new String[]{String.valueOf(downloadId)});
+ maybeEnqueueDownload(context);
+ }
+
+ return entry;
+ }
+
+ public void iterateDownloadEntries(DownloadHandler callback) {
+ try (Cursor cursor = db.rawQuery("select * from " + DOWNLOAD_DATABASE.SONG.TABLE, null)) {
+ while (cursor.moveToNext()) {
+ callback.handle(getDownloadEntry(cursor));
+ }
+ }
+ }
+
+ private DownloadEntry getDownloadEntry(Cursor cursor) {
+ DownloadEntry entry = new DownloadEntry();
+ entry.downloadId = cursor.getLong(cursor.getColumnIndex(DOWNLOAD_DATABASE.SONG.COLUMNS.DOWNLOAD_ID));
+ entry.url = Uri.parse(cursor.getString(cursor.getColumnIndex(DOWNLOAD_DATABASE.SONG.COLUMNS.URL)));
+ entry.fileName = cursor.getString(cursor.getColumnIndex(DOWNLOAD_DATABASE.SONG.COLUMNS.FILE_NAME));
+ entry.credentials = cursor.getString(cursor.getColumnIndex(DOWNLOAD_DATABASE.SONG.COLUMNS.CREDENTIALS));
+ entry.title = cursor.getString(cursor.getColumnIndex(DOWNLOAD_DATABASE.SONG.COLUMNS.TITLE));
+ entry.album = cursor.getString(cursor.getColumnIndex(DOWNLOAD_DATABASE.SONG.COLUMNS.ALBUM));
+ entry.artist = cursor.getString(cursor.getColumnIndex(DOWNLOAD_DATABASE.SONG.COLUMNS.ARTIST));
+ return entry;
+ }
+
+ public void remove(Uri url) {
+ db.delete(DOWNLOAD_DATABASE.SONG.TABLE, DOWNLOAD_DATABASE.SONG.COLUMNS.URL + "=?", new String[]{url.toString()});
+ }
+
+ public static class DownloadEntry {
+ public long downloadId;
+ public Uri url;
+ public String fileName;
+ public String credentials;
+ public String title;
+ public String album;
+ public String artist;
+ }
+
+ public interface DownloadHandler {
+ void handle(DownloadEntry entry);
+ }
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/download/DownloadFilenameStructure.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/download/DownloadFilenameStructure.java
new file mode 100644
index 000000000..03c07de05
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/download/DownloadFilenameStructure.java
@@ -0,0 +1,63 @@
+package uk.org.ngo.squeezer.download;
+
+import android.content.Context;
+
+import uk.org.ngo.squeezer.R;
+import uk.org.ngo.squeezer.framework.EnumWithText;
+import uk.org.ngo.squeezer.model.Song;
+
+public enum DownloadFilenameStructure implements EnumWithText{
+ NUMBER_TITLE(R.string.download_filename_structure_number_title) {
+ @Override
+ public String get(Song song) {
+ return formatTrackNumber(song.trackNum) + " - " + song.title;
+ }
+ },
+ ARTIST_TITLE(R.string.download_filename_structure_artist_title) {
+ @Override
+ public String get(Song song) {
+ return song.artist + " - " + song.title;
+ }
+ },
+ ARTIST_NUMBER_TITLE(R.string.download_filename_structure_artist_number_title) {
+ @Override
+ public String get(Song song) {
+ return song.artist + " - " + formatTrackNumber(song.trackNum) + " - " + song.title;
+ }
+ },
+ ALBUMARTIST_NUMBER_TITLE(R.string.download_filename_structure_albumartist_number_title) {
+ @Override
+ public String get(Song song) {
+ return song.albumArtist + " - " + formatTrackNumber(song.trackNum) + " - " + song.title;
+ }
+ },
+ TITLE(R.string.download_filename_structure_title) {
+ @Override
+ public String get(Song song) {
+ return song.title;
+ }
+ },
+ NUMBER_DOT_ARTIST_TITLE(R.string.download_filename_structure_number_dot_artist_title) {
+ @Override
+ public String get(Song song) {
+ return formatTrackNumber(song.trackNum) + ". " + song.artist + " - " + song.title;
+ }
+ };
+
+ private final int labelId;
+
+ DownloadFilenameStructure(int labelId) {
+ this.labelId = labelId;
+ }
+
+ @Override
+ public String getText(Context context) {
+ return context.getString(labelId);
+ }
+
+ public abstract String get(Song song);
+
+ private static String formatTrackNumber(int trackNumber) {
+ return String.format("%02d", trackNumber);
+ }
+}
\ No newline at end of file
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/download/DownloadPathStructure.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/download/DownloadPathStructure.java
new file mode 100644
index 000000000..6c5fdac80
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/download/DownloadPathStructure.java
@@ -0,0 +1,55 @@
+package uk.org.ngo.squeezer.download;
+
+import android.content.Context;
+
+import java.io.File;
+
+import uk.org.ngo.squeezer.R;
+import uk.org.ngo.squeezer.framework.EnumWithText;
+import uk.org.ngo.squeezer.model.Song;
+
+public enum DownloadPathStructure implements EnumWithText{
+ ARTIST_ARTISTALBUM(R.string.download_path_structure_artist_artistalbum) {
+ @Override
+ public String get(Song song) {
+ return new File(song.artist, song.artist + " - " + song.album).getPath();
+ }
+ },
+ ARTIST_ALBUM(R.string.download_path_structure_artist_album) {
+ @Override
+ public String get(Song song) {
+ return new File(song.artist, song.album).getPath();
+ }
+ },
+ ARTISTALBUM(R.string.download_path_structure_artistalbum) {
+ @Override
+ public String get(Song song) {
+ return song.artist + " - " + song.album;
+ }
+ },
+ ALBUM(R.string.download_path_structure_album) {
+ @Override
+ public String get(Song song) {
+ return song.album;
+ }
+ },
+ ARTIST(R.string.download_path_structure_artist) {
+ @Override
+ public String get(Song song) {
+ return song.artist;
+ }
+ };
+
+ private final int labelId;
+
+ public abstract String get(Song song);
+
+ DownloadPathStructure(int labelId) {
+ this.labelId = labelId;
+ }
+
+ @Override
+ public String getText(Context context) {
+ return context.getString(labelId);
+ }
+}
\ No newline at end of file
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/download/DownloadStatusReceiver.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/download/DownloadStatusReceiver.java
new file mode 100644
index 000000000..06e300fd4
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/download/DownloadStatusReceiver.java
@@ -0,0 +1,235 @@
+/*
+ * Copyright (c) 2014 Kurt Aaholst
+ *
+ * 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 uk.org.ngo.squeezer.download;
+
+import android.app.DownloadManager;
+import android.app.PendingIntent;
+import android.content.BroadcastReceiver;
+import android.content.ContentResolver;
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.Intent;
+import android.database.Cursor;
+import android.media.MediaScannerConnection;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Environment;
+import android.provider.MediaStore;
+import android.util.Log;
+
+import androidx.annotation.RequiresApi;
+import androidx.core.app.NotificationCompat;
+import androidx.core.app.NotificationManagerCompat;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+
+import uk.org.ngo.squeezer.R;
+import uk.org.ngo.squeezer.Util;
+import uk.org.ngo.squeezer.service.SqueezeService;
+
+
+/**
+ * Handle events from the download manager
+ *
+ * This class is registered in the manifest.
+ */
+public class DownloadStatusReceiver extends BroadcastReceiver {
+ private static final String TAG = DownloadStatusReceiver.class.getSimpleName();
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (DownloadManager.ACTION_NOTIFICATION_CLICKED.equals(intent.getAction())) {
+ handleUserRequest(context);
+ }
+ if (DownloadManager.ACTION_DOWNLOAD_COMPLETE.equals(intent.getAction())) {
+ handleDownloadComplete(context, intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0));
+ }
+ }
+
+ private void handleUserRequest(Context context) {
+ Log.i(TAG, "Download notification clicked");
+ Intent intent = new Intent(context, CancelDownloadsActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ context.startActivity(intent);
+ }
+
+ private void handleDownloadComplete(Context context, long id) {
+ final DownloadDatabase downloadDatabase = new DownloadDatabase(context);
+ final DownloadManager downloadManager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE);
+ final DownloadManager.Query query = new DownloadManager.Query().setFilterById(id);
+
+ Log.i(TAG, "download complete: " + id);
+ try (Cursor cursor = downloadManager.query(query)) {
+ if (!cursor.moveToNext()) {
+ // Download complete events may still come in, even after DownloadManager.remove is
+ // called, so don't log this
+ //Logger.logError(TAG, "Download manager does not have an entry for " + id);
+ return;
+ }
+
+ int downloadId = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_ID));
+ int status = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS));
+ int reason = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_REASON));
+ String title = cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_TITLE));
+ String url = cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_URI));
+ Uri local_url = Uri.parse(cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)));
+ Log.i(TAG, "download complete(" + title + "): " + id);
+
+ final DownloadDatabase.DownloadEntry downloadEntry = downloadDatabase.popDownloadEntry(context, downloadId);
+ if (downloadEntry == null) {
+ // TODO remote logging
+ Log.e(TAG, "Download database does not have an entry for " + format(status, reason, title, url, local_url));
+ return;
+ }
+ if (status != DownloadManager.STATUS_SUCCESSFUL) {
+ // TODO remote logging
+ Log.e(TAG, "Unsuccessful download " + format(status, reason, title, url, local_url));
+ return;
+ }
+
+ try {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
+ addToMediaStorage(context, downloadEntry, local_url);
+ } else {
+ addToMediaLibrary(context, downloadEntry, local_url);
+ }
+ } catch (IOException e) {
+ // TODO remote logging
+ Log.e(TAG, "IOException moving downloaded file", e);
+ }
+ }
+ }
+
+ private String format(int status, int reason, String title, String url, Uri local_url) {
+ return "{status:" + status + ", reason:" + reason + ", title:'" + title + "', url:'" + url + "', local url:'" + local_url + "'}";
+ }
+
+ private void addToMediaStorage(Context context, DownloadDatabase.DownloadEntry downloadEntry, Uri local_url) throws IOException {
+ File destinationFile = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC), downloadEntry.fileName);
+ File destFolder = destinationFile.getParentFile();
+
+ if (destFolder != null && !destFolder.exists()) {
+ if (!destFolder.mkdirs()) {
+ throw new IOException("Cant create folder for '" + destinationFile + "'");
+ }
+ }
+ Util.moveFile(context.getContentResolver(), local_url, Uri.fromFile(destinationFile));
+
+ MediaScannerConnection.scanFile(
+ context.getApplicationContext(),
+ new String[]{destinationFile.getAbsolutePath()},
+ null,
+ new DownloadOnScanCompletedListener(context, downloadEntry)
+ );
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.Q)
+ private void addToMediaLibrary(Context context, DownloadDatabase.DownloadEntry downloadEntry, Uri local_url) throws IOException {
+ ContentResolver resolver = context.getContentResolver();
+ Uri audioCollection = MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY);
+ Uri uri = null;
+
+ String[] projection = {MediaStore.Audio.AudioColumns._ID};
+ String selection = MediaStore.Audio.AudioColumns.TITLE + "=? and " + MediaStore.Audio.Media.ALBUM + "=? and " + MediaStore.Audio.Media.ARTIST + "=?";
+ String[] selectionArgs = new String[]{downloadEntry.title, downloadEntry.album, downloadEntry.artist};
+ try (Cursor cursor = resolver.query(audioCollection, projection, selection, selectionArgs, null)) {
+ if (cursor != null && cursor.moveToFirst()) {
+ uri = ContentUris.withAppendedId(audioCollection, cursor.getLong(cursor.getColumnIndex(MediaStore.Audio.AudioColumns._ID)));
+ Log.i(TAG, downloadEntry.title + " found in media library: " + uri);
+ }
+ }
+ if (uri == null) {
+ File file = new File(downloadEntry.fileName);
+ File parent = file.getParentFile();
+ ContentValues songDetails = new ContentValues();
+ songDetails.put(MediaStore.Audio.Media.DISPLAY_NAME, file.getName());
+ if (parent != null) {
+ songDetails.put(MediaStore.Audio.Media.RELATIVE_PATH, new File(Environment.DIRECTORY_MUSIC, parent.getPath()).getPath());
+ }
+
+ // Attempt to look up mime type
+ String mimeType = resolver.getType(local_url);
+ if (mimeType == null) {
+ mimeType = Files.probeContentType(file.toPath());
+ }
+ if (mimeType != null) {
+ songDetails.put(MediaStore.Audio.Media.MIME_TYPE, mimeType);
+ }
+
+ uri = resolver.insert(audioCollection, songDetails);
+ Log.i(TAG, downloadEntry.title + " added to media library: " + uri);
+ }
+ if (uri == null) {
+ throw new IOException("Failed to insert downloaded file: " + downloadEntry.fileName);
+ }
+
+ Util.moveFile(resolver, local_url, uri);
+ }
+
+ private static class DownloadOnScanCompletedListener implements MediaScannerConnection.OnScanCompletedListener {
+ private final Context context;
+ private final DownloadDatabase.DownloadEntry downloadEntry;
+
+ DownloadOnScanCompletedListener(Context context, DownloadDatabase.DownloadEntry downloadEntry) {
+ this.context = context;
+ this.downloadEntry = downloadEntry;
+ }
+
+ @Override
+ public void onScanCompleted(String path, final Uri uri) {
+ Log.i(TAG, "onScanCompleted('" + path + "'): " + uri);
+ if (uri == null) {
+ // Scanning failed, probably the file format is not supported.
+ Log.i(TAG, "'" + path + "' could not be added to the media database");
+ if (!new File(path).delete()) {
+ // TODO remote logging
+ Log.e(TAG, "Could not delete '" + path + "', which could not be added to the media database");
+ }
+ notifyFailedMediaScan(downloadEntry.fileName);
+ }
+ }
+
+ private void notifyFailedMediaScan(String fileName) {
+ String name = Util.getBaseName(fileName);
+
+ // Content intent is required on some API levels even if
+ // https://developer.android.com/guide/topics/ui/notifiers/notifications.html
+ // says it's optional
+ PendingIntent emptyPendingIntent = PendingIntent.getService(
+ context,
+ 0,
+ new Intent(), //Dummy Intent do nothing
+ 0);
+
+ final NotificationCompat.Builder builder = new NotificationCompat.Builder(context, SqueezeService.NOTIFICATION_CHANNEL_ID);
+ builder.setContentIntent(emptyPendingIntent);
+ builder.setOngoing(false);
+ builder.setOnlyAlertOnce(true);
+ builder.setAutoCancel(true);
+ builder.setSmallIcon(R.drawable.squeezer_notification);
+ builder.setTicker(name + " " + context.getString(R.string.NOTIFICATION_DOWNLOAD_MEDIA_SCANNER_ERROR));
+ builder.setContentTitle(name);
+ builder.setContentText(context.getString(R.string.NOTIFICATION_DOWNLOAD_MEDIA_SCANNER_ERROR));
+
+ final NotificationManagerCompat nm = NotificationManagerCompat.from(context);
+ nm.notify(SqueezeService.DOWNLOAD_ERROR, builder.build());
+ }
+ }
+
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/BaseActivity.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/BaseActivity.java
new file mode 100644
index 000000000..a00b5ccb5
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/BaseActivity.java
@@ -0,0 +1,550 @@
+/*
+ * Copyright (c) 2011 Kurt Aaholst
+ *
+ * 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 uk.org.ngo.squeezer.framework;
+
+import android.Manifest;
+import android.annotation.TargetApi;
+import android.app.ActivityManager;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.content.pm.PackageManager;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.IBinder;
+import androidx.annotation.CallSuper;
+import androidx.annotation.DrawableRes;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.app.NavUtils;
+import androidx.core.app.TaskStackBuilder;
+import androidx.appcompat.app.ActionBar;
+import androidx.appcompat.app.AppCompatActivity;
+import android.text.TextUtils;
+import android.util.Log;
+import android.util.TypedValue;
+import android.view.KeyEvent;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import uk.org.ngo.squeezer.Preferences;
+import uk.org.ngo.squeezer.dialog.AlertEventDialog;
+import uk.org.ngo.squeezer.dialog.DownloadDialog;
+import uk.org.ngo.squeezer.itemlist.HomeActivity;
+import uk.org.ngo.squeezer.R;
+import uk.org.ngo.squeezer.VolumePanel;
+import uk.org.ngo.squeezer.model.Action;
+import uk.org.ngo.squeezer.model.DisplayMessage;
+import uk.org.ngo.squeezer.model.JiveItem;
+import uk.org.ngo.squeezer.model.Player;
+import uk.org.ngo.squeezer.model.PlayerState;
+import uk.org.ngo.squeezer.service.ISqueezeService;
+import uk.org.ngo.squeezer.service.SqueezeService;
+import uk.org.ngo.squeezer.service.event.AlertEvent;
+import uk.org.ngo.squeezer.service.event.DisplayEvent;
+import uk.org.ngo.squeezer.service.event.PlayerVolume;
+import uk.org.ngo.squeezer.util.ImageFetcher;
+import uk.org.ngo.squeezer.util.SqueezePlayer;
+import uk.org.ngo.squeezer.util.ThemeManager;
+
+/**
+ * Common base class for all activities in Squeezer.
+ *
+ * @author Kurt Aaholst
+ */
+public abstract class BaseActivity extends AppCompatActivity {
+ private static final String CURRENT_DOWNLOAD_ITEM = "CURRENT_DOWNLOAD_ITEM";
+
+
+ private static final String TAG = BaseActivity.class.getName();
+
+ @Nullable
+ private ISqueezeService mService = null;
+
+ private final ThemeManager mTheme = new ThemeManager();
+ private int mThemeId = ThemeManager.getDefaultTheme().mThemeId;
+
+ /** Records whether the activity has registered on the service's event bus. */
+ private boolean mRegisteredOnEventBus;
+
+ private SqueezePlayer squeezePlayer;
+
+ /** Whether volume changes should be ignored. */
+ private boolean mIgnoreVolumeChange;
+
+ /** True if bindService() completed. */
+ private boolean boundService = false;
+
+ /** Volume control panel. */
+ @Nullable
+ private VolumePanel mVolumePanel;
+
+ /** Set this to true to stop displaying icon-based showBrieflies */
+ protected boolean ignoreIconMessages = false;
+
+ /**
+ * @return The squeezeservice, or null if not bound
+ */
+ @Nullable
+ public ISqueezeService getService() {
+ return mService;
+ }
+
+ public int getThemeId() {
+ return mThemeId;
+ }
+
+ private final ServiceConnection serviceConnection = new ServiceConnection() {
+ @Override
+ public void onServiceConnected(ComponentName name, IBinder binder) {
+ mService = (ISqueezeService) binder;
+ BaseActivity.this.onServiceConnected(mService);
+ }
+
+ @Override
+ public void onServiceDisconnected(ComponentName name) {
+ mService = null;
+ }
+ };
+
+ protected boolean addActionBar(){
+ return true;
+ }
+
+ @Override
+ @CallSuper
+ protected void onCreate(android.os.Bundle savedInstanceState) {
+ mTheme.onCreate(this);
+ super.onCreate(savedInstanceState);
+
+ if (addActionBar()) {
+ // Set the icon as the home button, and display it.
+ ActionBar actionBar = getSupportActionBar();
+ if (actionBar != null) {
+ actionBar.setHomeAsUpIndicator(R.drawable.ic_action_home);
+ actionBar.setDisplayHomeAsUpEnabled(true);
+ }
+ }
+
+ boundService = bindService(new Intent(this, SqueezeService.class), serviceConnection,
+ Context.BIND_AUTO_CREATE);
+ Log.d(TAG, "did bindService; serviceStub = " + getService());
+
+ if (savedInstanceState != null)
+ currentDownloadItem = savedInstanceState.getParcelable(CURRENT_DOWNLOAD_ITEM);
+ }
+
+ @Override
+ protected void onSaveInstanceState(Bundle outState) {
+ outState.putParcelable(CURRENT_DOWNLOAD_ITEM, currentDownloadItem);
+ super.onSaveInstanceState(outState);
+ }
+
+ @Override
+ public void setTheme(int resId) {
+ super.setTheme(resId);
+ mThemeId = resId;
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+
+ mTheme.onResume(this);
+
+ if (mService != null) {
+ maybeRegisterOnEventBus(mService);
+ }
+
+ mVolumePanel = new VolumePanel(this);
+
+ // If SqueezePlayer is installed, start it
+ squeezePlayer = SqueezePlayer.maybeStartControllingSqueezePlayer(this);
+
+ // Ensure that any image fetching tasks started by this activity do not finish prematurely.
+ ImageFetcher.getInstance(this).setExitTasksEarly(false);
+ }
+
+ @Override
+ @CallSuper
+ public void onPause() {
+ // At least some Samsung devices call onPause without ensuring that onResume is called
+ // first, per https://code.google.com/p/android/issues/detail?id=74464, so mVolumePanel
+ // may be null on those devices.
+ if (mVolumePanel != null) {
+ mVolumePanel.dismiss();
+ mVolumePanel = null;
+ }
+
+ if (squeezePlayer != null) {
+ squeezePlayer.stopControllingSqueezePlayer();
+ squeezePlayer = null;
+ }
+ if (mRegisteredOnEventBus) {
+ // If we are not bound to the service, it's process is no longer
+ // running, so the callbacks are already cleaned up.
+ if (mService != null) {
+ mService.getEventBus().unregister(this);
+ mService.cancelItemListRequests(this);
+ }
+ mRegisteredOnEventBus = false;
+ }
+
+ // Ensure that any pending image fetching tasks are unpaused, and finish quickly.
+ ImageFetcher imageFetcher = ImageFetcher.getInstance(this);
+ imageFetcher.setExitTasksEarly(true);
+ imageFetcher.setPauseWork(false);
+
+ super.onPause();
+ }
+
+ /**
+ * Clear the image memory cache if memory gets low.
+ */
+ @Override
+ @CallSuper
+ public void onLowMemory() {
+ ImageFetcher.onLowMemory();
+ }
+
+ @Override
+ @CallSuper
+ public void onDestroy() {
+ super.onDestroy();
+ if (boundService) {
+ unbindService(serviceConnection);
+ }
+ }
+
+ /** Fix for https://code.google.com/p/android/issues/detail?id=63570. */
+ private boolean mIsRestoredToTop;
+
+ @Override
+ protected void onNewIntent(Intent intent) {
+ super.onNewIntent(intent);
+ if ((intent.getFlags() | Intent.FLAG_ACTIVITY_REORDER_TO_FRONT) > 0) {
+ mIsRestoredToTop = true;
+ }
+ }
+
+ @Override
+ @TargetApi(Build.VERSION_CODES.KITKAT)
+ public void finish() {
+ super.finish();
+ if (Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT && !isTaskRoot()
+ && mIsRestoredToTop) {
+ // 4.4.2 platform issues for FLAG_ACTIVITY_REORDER_TO_FRONT,
+ // reordered activity back press will go to home unexpectedly,
+ // Workaround: move reordered activity current task to front when it's finished.
+ ActivityManager tasksManager = (ActivityManager) getSystemService(ACTIVITY_SERVICE);
+ tasksManager.moveTaskToFront(getTaskId(), ActivityManager.MOVE_TASK_NO_USER_ACTION);
+ }
+ }
+
+ /**
+ * Performs any actions necessary after the service has been connected. Derived classes
+ * should call through to the base class.
+ *
+ * - Invalidates the options menu so that menu items can be adjusted based on
+ * the state of the service connection.
+ * - Ensures that callbacks are registered.
+ *
+ *
+ * @param service The connection to the bound service.
+ */
+ @CallSuper
+ protected void onServiceConnected(@NonNull ISqueezeService service) {
+ Log.d(TAG, "onServiceConnected");
+ supportInvalidateOptionsMenu();
+ maybeRegisterOnEventBus(service);
+ }
+
+ /**
+ * Conditionally registers with the service's EventBus.
+ *
+ * Registration can happen in {@link #onResume()} and {@link
+ * #onServiceConnected(uk.org.ngo.squeezer.service.ISqueezeService)}, this ensures that it only
+ * happens once.
+ *
+ * @param service The connection to the bound service.
+ */
+ private void maybeRegisterOnEventBus(@NonNull ISqueezeService service) {
+ if (!mRegisteredOnEventBus) {
+ service.getEventBus().registerSticky(this);
+ mRegisteredOnEventBus = true;
+ }
+ }
+
+ @Override
+ @CallSuper
+ public boolean onOptionsItemSelected(MenuItem item) {
+ switch (item.getItemId()) {
+ case android.R.id.home:
+ Intent upIntent = NavUtils.getParentActivityIntent(this);
+ if (upIntent != null) {
+ if (NavUtils.shouldUpRecreateTask(this, upIntent)) {
+ TaskStackBuilder.create(this)
+ .addNextIntentWithParentStack(upIntent)
+ .startActivities();
+ } else {
+ upIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);
+ NavUtils.navigateUpTo(this, upIntent);
+ }
+ } else {
+ HomeActivity.show(this);
+ }
+ return true;
+ }
+
+ return super.onOptionsItemSelected(item);
+ }
+
+
+ /*
+ * Intercept hardware volume control keys to control Squeezeserver
+ * volume.
+ *
+ * Change the volume when the key is depressed. Suppress the keyUp
+ * event, otherwise you get a notification beep as well as the volume
+ * changing.
+ */
+ @Override
+ @CallSuper
+ public boolean onKeyDown(int keyCode, KeyEvent event) {
+ switch (keyCode) {
+ case KeyEvent.KEYCODE_VOLUME_UP:
+ return changeVolumeBy(+5);
+ case KeyEvent.KEYCODE_VOLUME_DOWN:
+ return changeVolumeBy(-5);
+ }
+
+ return super.onKeyDown(keyCode, event);
+ }
+
+ @Override
+ @CallSuper
+ public boolean onKeyUp(int keyCode, @NonNull KeyEvent event) {
+ switch (keyCode) {
+ case KeyEvent.KEYCODE_VOLUME_UP:
+ case KeyEvent.KEYCODE_VOLUME_DOWN:
+ return true;
+ }
+
+ return super.onKeyUp(keyCode, event);
+ }
+
+ private boolean changeVolumeBy(int delta) {
+ ISqueezeService service = getService();
+ if (service == null) {
+ return false;
+ }
+ Log.v(TAG, "Adjust volume by: " + delta);
+ service.adjustVolumeBy(delta);
+ return true;
+ }
+
+ public void onEvent(PlayerVolume event) {
+ if (!mIgnoreVolumeChange && mVolumePanel != null && event.player == mService.getActivePlayer()) {
+ mVolumePanel.postVolumeChanged(event.volume, event.player.getName());
+ }
+ }
+
+ // Show the volume dialog.
+ public boolean showVolumePanel() {
+ if (mService != null) {
+ PlayerState playerState = mService.getPlayerState();
+ Player player = mService.getActivePlayer();
+
+ if (playerState != null && mVolumePanel != null) {
+ mVolumePanel.postVolumeChanged(playerState.getCurrentVolume(),
+ player == null ? "" : player.getName());
+ }
+
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ public void setIgnoreVolumeChange(boolean ignoreVolumeChange) {
+ mIgnoreVolumeChange = ignoreVolumeChange;
+ }
+
+ public void onEventMainThread(DisplayEvent displayEvent) {
+ boolean showMe = true;
+ DisplayMessage display = displayEvent.message;
+ View layout = getLayoutInflater().inflate(R.layout.display_message, findViewById(R.id.display_message_container));
+ ImageView artwork = layout.findViewById(R.id.artwork);
+ artwork.setVisibility(View.GONE);
+ ImageView icon = layout.findViewById(R.id.icon);
+ icon.setVisibility(View.GONE);
+ TextView text = layout.findViewById(R.id.text);
+ text.setVisibility(TextUtils.isEmpty(display.text) ? View.GONE : View.VISIBLE);
+ text.setText(display.text);
+
+ if (display.isIcon() || display.isMixed() || display.isPopupAlbum()) {
+ if (display.isIcon() && ignoreIconMessages) {
+ //icon based messages afre ignored for the now playing screen
+ showMe = false;
+ } else {
+ @DrawableRes int iconResource = display.getIconResource();
+ if (iconResource != 0) {
+ icon.setVisibility(View.VISIBLE);
+ icon.setImageResource(iconResource);
+ }
+ if (display.hasIcon()) {
+ artwork.setVisibility(View.VISIBLE);
+ ImageFetcher.getInstance(this).loadImage(display.icon, artwork);
+ }
+ }
+ } else if (display.isSong()) {
+ //These are for the NowPlaying screen, which we update via player status messages
+ showMe = false;
+ }
+
+ if (showMe) {
+ if (!(icon.getVisibility() == View.VISIBLE &&text.getVisibility() == View.VISIBLE)) {
+ layout.findViewById(R.id.divider).setVisibility(View.GONE);
+ }
+ int duration = (display.duration >=0 && display.duration <= 3000 ? Toast.LENGTH_SHORT : Toast.LENGTH_LONG);
+ Toast toast = new Toast(getApplicationContext());
+ //TODO handle duration == -1 => LENGTH.INDEFINITE and custom (server side) duration,
+ // once we have material design and BaseTransientBottomBar
+ toast.setDuration(duration);
+ toast.setView(layout);
+ toast.show();
+ }
+ }
+
+ public void onEventMainThread(AlertEvent alert) {
+ AlertEventDialog.show(getSupportFragmentManager(), alert.message.title, alert.message.text);
+ }
+
+ // Safe accessors
+
+ public boolean isConnected() {
+ return mService != null && mService.isConnected();
+ }
+
+ /**
+ * Perform the supplied action using parameters in item via
+ * {@link ISqueezeService#action(JiveItem, Action)}
+ *
+ * Navigate to nextWindow if it exists in action. The
+ * alreadyPopped parameter is used to modify nextWindow if any windows has already
+ * been popped by the Android system.
+ */
+ public void action(JiveItem item, Action action, int alreadyPopped) {
+ if (mService == null) {
+ return;
+ }
+
+ mService.action(item, action);
+ }
+
+ /**
+ * Same as calling {@link #action(JiveItem, Action, int)} with alreadyPopped = 0
+ */
+ public void action(JiveItem item, Action action) {
+ action(item, action, 0);
+ }
+
+ /**
+ * Perform the supplied action using parameters in item via
+ * {@link ISqueezeService#action(Action.JsonAction)}
+ */
+ public void action(Action.JsonAction action, int alreadyPopped) {
+ if (mService == null) {
+ return;
+ }
+
+ mService.action(action);
+ }
+
+ /**
+ * Initiate download of songs for the supplied item.
+ *
+ * @param item Song or item with songs to download
+ * @see ISqueezeService#downloadItem(JiveItem)
+ */
+ public void downloadItem(JiveItem item) {
+ if (new Preferences(this).isDownloadConfirmation()) {
+ DownloadDialog.show(getSupportFragmentManager(), item, new DownloadDialog.DownloadDialogListener() {
+ @Override
+ public void download(boolean persist) {
+ if (persist) {
+ new Preferences(BaseActivity.this).setDownloadConfirmation(false);
+ }
+ doDownload(item);
+ }
+
+ @Override
+ public void cancel(boolean persist) {
+ if (persist) {
+ new Preferences(BaseActivity.this).setDownloadEnabled(false);
+ }
+
+ }
+ });
+ } else {
+ doDownload(item);
+ }
+ }
+
+ private void doDownload(JiveItem item) {
+ if (Build.VERSION_CODES.M <= Build.VERSION.SDK_INT && Build.VERSION.SDK_INT < Build.VERSION_CODES.Q &&
+ checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
+ currentDownloadItem = item;
+ requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, 1);
+ } else
+ mService.downloadItem(item);
+ }
+
+ private JiveItem currentDownloadItem;
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
+ switch (requestCode) {
+ case 1:
+ if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+ if (currentDownloadItem != null) {
+ mService.downloadItem(currentDownloadItem);
+ currentDownloadItem = null;
+ } else
+ Toast.makeText(this, "Please select download again now that we have permission to save it", Toast.LENGTH_LONG).show();
+ } else
+ Toast.makeText(this, R.string.DOWNLOAD_REQUIRES_WRITE_PERMISSION, Toast.LENGTH_LONG).show();
+ break;
+ }
+ }
+
+ /**
+ * Look up an attribute resource styled for the current theme.
+ *
+ * @param attribute Attribute identifier to look up.
+ * @return The resource identifier for the given attribute.
+ */
+ public int getAttributeValue(int attribute) {
+ TypedValue v = new TypedValue();
+ getTheme().resolveAttribute(attribute, v, true);
+ return v.resourceId;
+ }
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/BaseItemView.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/BaseItemView.java
new file mode 100644
index 000000000..cfb2edd2f
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/BaseItemView.java
@@ -0,0 +1,351 @@
+/*
+ * Copyright (c) 2011 Kurt Aaholst
+ *
+ * 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 uk.org.ngo.squeezer.framework;
+
+import android.os.Parcelable.Creator;
+import androidx.annotation.IntDef;
+import androidx.annotation.LayoutRes;
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.CheckBox;
+import android.widget.ImageView;
+import android.widget.ProgressBar;
+import android.widget.RadioButton;
+import android.widget.TextView;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.reflect.Field;
+
+import uk.org.ngo.squeezer.R;
+import uk.org.ngo.squeezer.model.Item;
+import uk.org.ngo.squeezer.util.Reflection;
+import uk.org.ngo.squeezer.widget.SquareImageView;
+
+/**
+ * Represents the view hierarchy for a single {@link Item} subclass, suitable for displaying in a
+ * {@link ItemListActivity}.
+ *
+ * This class supports views that have a {@link TextView} to display the primary information about
+ * the {@link Item} and can optionally enable additional views. The layout is defined in {@code
+ * res/layout/list_item.xml}.
- A {@link SquareImageView} suitable for displaying icons
+ * - A second, smaller {@link TextView} for additional item information
- A {@link
+ * Button} that shows a disclosure triangle for a context menu
The view can
+ * display an item in one of two states. The primary state is when the data to be inserted in to
+ * the view is known, and represented by a complete {@link Item} subclass. The loading state is when
+ * the data type is known, but has not been fetched from the server yet.
+ *
+ * To customise the view's display create an int of {@link ViewParam} and pass it to
+ * {@link #setViewParams(int)} or {@link #setLoadingViewParams(int)} depending on whether
+ * you want to change the layout of the view in its primary state or the loading state. For example,
+ * if the primary state should show a context button you may not want to show that button while
+ * waiting for data to arrive.
+ *
+ * Override {@link #bindView(View, Item)} and {@link #bindView(View, String)} to
+ * control how data from the item is inserted in to the view.
+ *
+ * If you need a completely custom view hierarchy then override {@link #getAdapterView(View,
+ * ViewGroup, int, T, boolean)} and {@link #getAdapterView(View, ViewGroup, int, String)}.
+ *
+ * @param the Item subclass this view represents.
+ */
+public abstract class BaseItemView implements ItemView {
+
+ private final ItemListActivity mActivity;
+
+ private final LayoutInflater mLayoutInflater;
+
+ private Class mItemClass;
+
+ private Creator mCreator;
+
+ @IntDef(flag=true, value={
+ VIEW_PARAM_ICON, VIEW_PARAM_TWO_LINE, VIEW_PARAM_CONTEXT_BUTTON
+ })
+ @Retention(RetentionPolicy.SOURCE)
+ /* Parameters that control which additional views will be enabled in the item view. */
+ public @interface ViewParam {}
+ /** Adds a {@link SquareImageView} for displaying artwork or other iconography. */
+ public static final int VIEW_PARAM_ICON = 1;
+ /** Adds a second line for detail information ({@code R.id.text2}). */
+ public static final int VIEW_PARAM_TWO_LINE = 1 << 1;
+ /** Adds a button, with click handler, to display the context menu. */
+ public static final int VIEW_PARAM_CONTEXT_BUTTON = 1 << 2;
+
+ /**
+ * View parameters for a filled-in view. One primary line with context button.
+ */
+ @ViewParam private int mViewParams = VIEW_PARAM_CONTEXT_BUTTON;
+
+ /**
+ * View parameters for a view that is loading data. Primary line only.
+ */
+ @ViewParam private int mLoadingViewParams = 0;
+
+ /**
+ * A ViewHolder for the views that make up a complete list item.
+ */
+ public static class ViewHolder extends RecyclerView.ViewHolder {
+ public int position;
+
+ public ImageView icon;
+
+ public TextView text1;
+
+ public TextView text2;
+
+ public View contextMenuButtonHolder;
+ public Button contextMenuButton;
+ public ProgressBar contextMenuLoading;
+ public CheckBox contextMenuCheckbox;
+ public RadioButton contextMenuRadio;
+
+ public @ViewParam int viewParams;
+
+ public ViewHolder(@NonNull View view) {
+ super(view);
+ text1 = view.findViewById(R.id.text1);
+ text2 = view.findViewById(R.id.text2);
+ icon = view.findViewById(R.id.icon);
+ setContextMenu(view);
+ }
+
+ private void setContextMenu(View view) {
+ contextMenuButtonHolder = view.findViewById(R.id.context_menu);
+ if (contextMenuButtonHolder!= null) {
+ contextMenuButton = contextMenuButtonHolder.findViewById(R.id.context_menu_button);
+ contextMenuLoading = contextMenuButtonHolder.findViewById(R.id.loading_progress);
+ contextMenuCheckbox = contextMenuButtonHolder.findViewById(R.id.checkbox);
+ contextMenuRadio = contextMenuButtonHolder.findViewById(R.id.radio);
+ }
+ }
+ }
+
+ public BaseItemView(ItemListActivity activity) {
+ mActivity = activity;
+ mLayoutInflater = activity.getLayoutInflater();
+ }
+
+ @Override
+ public ItemListActivity getActivity() {
+ return mActivity;
+ }
+
+ public LayoutInflater getLayoutInflater() {
+ return mLayoutInflater;
+ }
+
+ /**
+ * Set the view parameters to use for the view when data is loaded.
+ */
+ protected void setViewParams(@ViewParam int viewParams) {
+ mViewParams = viewParams;
+ }
+
+ /**
+ * Set the view parameters to use for the view while data is being loaded.
+ */
+ protected void setLoadingViewParams(@ViewParam int viewParams) {
+ mLoadingViewParams = viewParams;
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public Class getItemClass() {
+ if (mItemClass == null) {
+ mItemClass = (Class) Reflection.getGenericClass(getClass(), ItemView.class,
+ 0);
+ if (mItemClass == null) {
+ throw new RuntimeException("Could not read generic argument for: " + getClass());
+ }
+ }
+ return mItemClass;
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public Creator getCreator() {
+ if (mCreator == null) {
+ Field field;
+ try {
+ field = getItemClass().getField("CREATOR");
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ try {
+ mCreator = (Creator) field.get(null);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+ return mCreator;
+ }
+
+ /**
+ * Returns a view suitable for displaying the data of item in a list. Item may not be null.
+ *
+ * Override this method and {@link #getAdapterView(View, ViewGroup, int, String)} if your subclass
+ * uses a different layout.
+ */
+ @Override
+ public View getAdapterView(View convertView, ViewGroup parent, int position, T item, boolean selected) {
+ View view = getAdapterView(convertView, parent, position, mViewParams);
+ bindView(view, item);
+ return view;
+ }
+
+ /**
+ * Binds the item's name to {@link ViewHolder#text1}.
+ *
+ * Override this instead of {@link #getAdapterView(View, ViewGroup, int, Item, boolean)} if the
+ * default layouts are sufficient.
+ *
+ * @param view The view that contains the {@link ViewHolder}
+ * @param item The item to be bound
+ */
+ public void bindView(final View view, final T item) {
+ final ViewHolder viewHolder = (ViewHolder) view.getTag();
+
+ viewHolder.text1.setText(item.getName());
+
+ if (viewHolder.contextMenuButton!= null) {
+ viewHolder.contextMenuButton.setOnClickListener(v -> showContextMenu(viewHolder, item));
+ }
+ }
+
+ /**
+ * Returns a view suitable for displaying the "Loading..." text.
+ *
+ * Override this method and {@link #getAdapterView(View, ViewGroup, int, Item, boolean)} if your
+ * extension uses a different layout.
+ */
+ @Override
+ public View getAdapterView(View convertView, ViewGroup parent, int position, String text) {
+ View view = getAdapterView(convertView, parent, position, mLoadingViewParams);
+ bindView(view, text);
+ return view;
+ }
+
+ /**
+ * Binds the text to {@link ViewHolder#text1}.
+ *
+ * Override this instead of {@link #getAdapterView(View, ViewGroup, int, String)} if the default
+ * layout is sufficient.
+ *
+ * @param view The view that contains the {@link ViewHolder}
+ * @param text The text to set in the view.
+ */
+ public void bindView(View view, String text) {
+ ViewHolder viewHolder = (ViewHolder) view.getTag();
+
+ viewHolder.text1.setText(text);
+ }
+
+ /**
+ * Creates a view from {@code convertView} and the {@code viewParams} using the default layout
+ * {@link R.layout#list_item}
+ *
+ * @param convertView View to reuse if possible.
+ * @param parent The {@link ViewGroup} to inherit properties from.
+ * @param viewParams A set of 0 or more {@link ViewParam} to customise the view.
+ *
+ * @return convertView if it can be reused, or a new view
+ */
+ public View getAdapterView(View convertView, ViewGroup parent, int position, @ViewParam int viewParams) {
+ return getAdapterView(convertView, parent, viewParams, position, R.layout.list_item);
+ }
+
+ /**
+ * Creates a view from {@code convertView} and the {@code viewParams}.
+ *
+ * @param convertView View to reuse if possible.
+ * @param parent The {@link ViewGroup} to inherit properties from.
+ * @param viewParams A set of 0 or more {@link ViewParam} to customise the view.
+ * @param layoutResource The layout resource defining the item view
+ *
+ * @return convertView if it can be reused, or a new view
+ */
+ protected View getAdapterView(View convertView, ViewGroup parent, int position, @ViewParam int viewParams, @LayoutRes int layoutResource) {
+ ViewHolder viewHolder =
+ (convertView != null && convertView.getTag() instanceof ViewHolder)
+ ? (ViewHolder) convertView.getTag()
+ : null;
+
+ if (viewHolder == null) {
+ convertView = getLayoutInflater().inflate(layoutResource, parent, false);
+ viewHolder = createViewHolder(convertView);
+ setViewParams(viewParams, viewHolder);
+ convertView.setTag(viewHolder);
+ }
+
+ viewHolder.position = position;
+
+ // If the view parameters are different then reset the visibility of child views and hook
+ // up any standard behaviours.
+ if (viewParams != viewHolder.viewParams) {
+ setViewParams(viewParams, viewHolder);
+ }
+
+ return convertView;
+ }
+
+ private void setViewParams(@ViewParam int viewParams, ViewHolder viewHolder) {
+ viewHolder.icon.setVisibility(
+ (viewParams & VIEW_PARAM_ICON) != 0 ? View.VISIBLE : View.GONE);
+ viewHolder.text2.setVisibility(
+ (viewParams & VIEW_PARAM_TWO_LINE) != 0 ? View.VISIBLE : View.GONE);
+
+ if (viewHolder.contextMenuButtonHolder != null) {
+ viewHolder.contextMenuButtonHolder.setVisibility(
+ (viewParams & VIEW_PARAM_CONTEXT_BUTTON) != 0 ? View.VISIBLE : View.GONE);
+ }
+ viewHolder.viewParams = viewParams;
+ }
+
+ public ViewHolder createViewHolder(View itemView) {
+ return new ViewHolder(itemView);
+ }
+
+ @Override
+ public boolean isSelectable(T item) {
+ return (item.getId() != null);
+ }
+
+ @Override
+ public boolean onItemSelected(View view, int index, T item) {
+ return false;
+ }
+
+ @Override
+ public void onGroupSelected(View view, T[] items) {
+
+ }
+
+ @Override
+ public boolean isSelected(T item) {
+ return false;
+ }
+
+ @Override
+ public void showContextMenu(ViewHolder v, T item) {
+ }
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/BaseListActivity.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/BaseListActivity.java
new file mode 100644
index 000000000..f59b4e7ab
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/BaseListActivity.java
@@ -0,0 +1,239 @@
+/*
+ * Copyright (c) 2011 Kurt Aaholst
+ *
+ * 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 uk.org.ngo.squeezer.framework;
+
+
+import android.os.Bundle;
+import androidx.annotation.MainThread;
+import android.widget.AbsListView;
+import android.widget.ImageView;
+import android.widget.ListView;
+
+import java.util.List;
+import java.util.Map;
+
+import uk.org.ngo.squeezer.R;
+import uk.org.ngo.squeezer.itemlist.IServiceItemListCallback;
+import uk.org.ngo.squeezer.model.Item;
+import uk.org.ngo.squeezer.service.event.HandshakeComplete;
+import uk.org.ngo.squeezer.util.ImageFetcher;
+
+
+/**
+ * A generic base class for an activity to list items of a particular SqueezeServer data type. The
+ * data type is defined by the generic type argument, and must be an extension of {@link Item}. You
+ * must provide an {@link ItemView} to provide the view logic used by this activity. This is done by
+ * implementing {@link #createItemView()}.
+ *
+ * When the activity is first created ({@link #onCreate(Bundle)}), an empty {@link ItemAdapter}
+ * is created using the provided {@link ItemView}. See {@link ItemListActivity} for see details of
+ * ordering and receiving of list items from SqueezeServer, and handling of item selection.
+ *
+ * @param Denotes the class of the items this class should list
+ *
+ * @author Kurt Aaholst
+ */
+public abstract class BaseListActivity extends ItemListActivity implements IServiceItemListCallback {
+
+ /**
+ * Tag for first visible position in mRetainFragment.
+ */
+ private static final String TAG_POSITION = "position";
+
+ /**
+ * Tag for itemAdapter in mRetainFragment.
+ */
+ public static final String TAG_ADAPTER = "adapter";
+
+ private ItemAdapter itemAdapter;
+
+ /**
+ * Can't do much here, as content is based on settings, and which data to display, which is controlled by data
+ * returned from server.
+ *
+ * See {@link #setupListView(AbsListView)} and {@link #onItemsReceived(int, int, Map, List, Class)} for the actual setup of
+ * views and adapter
+ */
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(getContentView());
+ }
+
+ @Override
+ protected AbsListView setupListView(AbsListView listView) {
+ listView.setOnItemClickListener((parent, view, position, id) -> getItemAdapter().onItemSelected(view, position));
+
+ listView.setOnScrollListener(new ScrollListener());
+
+ listView.setRecyclerListener(view -> {
+ // Release strong reference when a view is recycled
+ final ImageView imageView = view.findViewById(R.id.icon);
+ if (imageView != null) {
+ imageView.setImageBitmap(null);
+ }
+ });
+
+ setupAdapter(listView);
+
+ return listView;
+ }
+
+ @MainThread
+ public void onEventMainThread(HandshakeComplete event) {
+ super.onEventMainThread(event);
+ if (!needPlayer() || getService().getActivePlayer() != null) {
+ maybeOrderVisiblePages(getListView());
+ } else {
+ showEmptyView();
+ }
+ }
+
+ /**
+ * Returns the ID of a content view to be used by this list activity.
+ *
+ * The content view must contain a {@link AbsListView} with the id {@literal item_list} in order
+ * to be valid.
+ *
+ * @return The ID
+ */
+ protected int getContentView() {
+ return R.layout.slim_browser_layout;
+ }
+
+ /**
+ * @return A new view logic to be used by this activity
+ */
+ abstract protected ItemView createItemView();
+
+ /**
+ * Set our adapter on the list view.
+ *
+ * This can't be done in {@link #onCreate(android.os.Bundle)} because getView might be called
+ * before the handshake is complete, so we need to delay it.
+ *
+ * However when we set the adapter after onCreate the list is scrolled to top, so we retain the
+ * visible position.
+ *
+ * Call this method after the handshake is complete.
+ */
+ private void setupAdapter(AbsListView listView) {
+ listView.setAdapter(getItemAdapter());
+
+ Integer position = (Integer) getRetainedValue(TAG_POSITION);
+ if (position != null) {
+ if (listView instanceof ListView) {
+ ((ListView) listView).setSelectionFromTop(position, 0);
+ } else {
+ listView.setSelection(position);
+ }
+ }
+ }
+
+ @Override
+ protected void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ saveVisiblePosition();
+ }
+
+ /**
+ * Store the first visible position of {@link #getListView()}, in the retain fragment, so
+ * we can later retrieve it.
+ *
+ * @see android.widget.AbsListView#getFirstVisiblePosition()
+ */
+ private void saveVisiblePosition() {
+ putRetainedValue(TAG_POSITION, getListView().getFirstVisiblePosition());
+ }
+
+ /**
+ * @return The current {@link ItemAdapter}'s {@link ItemView}
+ */
+ public ItemView getItemView() {
+ return getItemAdapter().getItemView();
+ }
+
+ /**
+ * @return The current {@link ItemAdapter}, creating it if necessary.
+ */
+ public ItemAdapter getItemAdapter() {
+ if (itemAdapter == null) {
+ //noinspection unchecked
+ itemAdapter = (ItemAdapter) getRetainedValue(TAG_ADAPTER);
+ if (itemAdapter == null) {
+ itemAdapter = createItemListAdapter(createItemView());
+ putRetainedValue(TAG_ADAPTER, itemAdapter);
+ } else {
+ // We have just retained the item adapter, we need to create a new
+ // item view logic, cause it holds a reference to the old activity
+ itemAdapter.setItemView(createItemView());
+ // Update views with the count from the retained item adapter
+ itemAdapter.onCountUpdated();
+ }
+ }
+
+ return itemAdapter;
+ }
+
+ @Override
+ protected void clearItemAdapter() {
+ getItemAdapter().clear();
+ }
+
+ protected ItemAdapter createItemListAdapter(ItemView itemView) {
+ return new ItemAdapter<>(itemView);
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ protected void updateAdapter(int count, int start, List items, Class dataType) {
+ getItemAdapter().update(count, start, (List) items);
+ }
+
+ @Override
+ public void onItemsReceived(int count, int start, Map parameters, List items, Class dataType) {
+ super.onItemsReceived(count, start, items, dataType);
+ }
+
+ @Override
+ public Object getClient() {
+ return this;
+ }
+
+ protected class ScrollListener extends ItemListActivity.ScrollListener {
+
+ ScrollListener() {
+ super();
+ }
+
+ /**
+ * Pauses cache disk fetches if the user is flinging the list, or if their finger is still
+ * on the screen.
+ */
+ @Override
+ public void onScrollStateChanged(AbsListView listView, int scrollState) {
+ super.onScrollStateChanged(listView, scrollState);
+
+ if (scrollState == AbsListView.OnScrollListener.SCROLL_STATE_FLING ||
+ scrollState == AbsListView.OnScrollListener.SCROLL_STATE_TOUCH_SCROLL) {
+ ImageFetcher.getInstance(BaseListActivity.this).setPauseWork(true);
+ } else {
+ ImageFetcher.getInstance(BaseListActivity.this).setPauseWork(false);
+ }
+ }
+ }
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/EnumIdLookup.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/EnumIdLookup.java
new file mode 100644
index 000000000..67f37709c
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/EnumIdLookup.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2015 Kurt Aaholst
+ *
+ * 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 uk.org.ngo.squeezer.framework;
+
+import android.util.SparseArray;
+
+/**
+ * Reverse mapping of EnumWithId.
+ *
+ * Enables lookup of enum value via it's id.
+ */
+public class EnumIdLookup & EnumWithId> {
+
+ private final SparseArray map = new SparseArray<>();
+
+ public EnumIdLookup(Class enumType) {
+ for (E v : enumType.getEnumConstants()) {
+ map.put(v.getId(), v);
+ }
+ }
+
+ public E get(int num) {
+ return map.get(num);
+ }
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/EnumWithId.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/EnumWithId.java
new file mode 100644
index 000000000..8ec797abb
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/EnumWithId.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2015 Kurt Aaholst
+ *
+ * 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 uk.org.ngo.squeezer.framework;
+
+/**
+ * Helper interface to associate an id with an enum value
+ */
+public interface EnumWithId {
+ int getId();
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/EnumWithText.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/EnumWithText.java
new file mode 100644
index 000000000..c14c9e415
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/EnumWithText.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2015 Kurt Aaholst
+ *
+ * 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 uk.org.ngo.squeezer.framework;
+
+import android.content.Context;
+
+/**
+ * Helper interface to associate a text suitable for display in the UI with an enum value.
+ */
+public interface EnumWithText {
+ String getText(Context context);
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/ItemAdapter.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/ItemAdapter.java
new file mode 100644
index 000000000..780f653d5
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/ItemAdapter.java
@@ -0,0 +1,323 @@
+/*
+ * Copyright (c) 2011 Kurt Aaholst
+ *
+ * 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 uk.org.ngo.squeezer.framework;
+
+import android.util.SparseArray;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+
+import java.util.List;
+
+import uk.org.ngo.squeezer.R;
+import uk.org.ngo.squeezer.model.Item;
+
+
+/**
+ * A generic class for an adapter to list items of a particular SqueezeServer data type. The data
+ * type is defined by the generic type argument, and must be an extension of {@link Item}.
+ *
+ * Normally there is no need to extend this, as we delegate all type dependent stuff to
+ * {@link ItemView}.
+ *
+ * @param Denotes the class of the items this class should list
+ *
+ * @author Kurt Aaholst
+ * @see ItemView
+ */
+public class ItemAdapter extends BaseAdapter {
+
+ /**
+ * View logic for this adapter
+ */
+ private ItemView mItemView;
+
+ /**
+ * List of items, possibly headed with an empty item.
+ *
+ * As the items are received from SqueezeServer they will be inserted in the list.
+ */
+ private int count;
+
+ private final SparseArray pages = new SparseArray<>();
+
+ /**
+ * This is set if the list shall start with an empty item.
+ */
+ private final boolean mEmptyItem;
+
+ /**
+ * Text to display before the items are received from SqueezeServer
+ */
+ private final String loadingText;
+
+ /**
+ * Number of elements to by fetched at a time
+ */
+ private final int pageSize;
+
+ /**
+ * Index of the latest selected item see {@link #onItemSelected(View, int)}
+ */
+ private int selectedIndex;
+
+ /**
+ * Creates a new adapter. Initially the item list is populated with items displaying the
+ * localized "loading" text. Call {@link #update(int, int, List)} as items arrives from
+ * SqueezeServer.
+ *
+ * @param itemView The {@link ItemView} to use with this adapter
+ * @param emptyItem If set the list of items shall start with an empty item
+ */
+ public ItemAdapter(ItemView itemView, boolean emptyItem) {
+ mItemView = itemView;
+ mEmptyItem = emptyItem;
+ loadingText = itemView.getActivity().getString(R.string.loading_text);
+ pageSize = itemView.getActivity().getResources().getInteger(R.integer.PageSize);
+ pages.clear();
+ }
+
+ /**
+ * Calls {@link #(ItemView, boolean)}, with emptyItem = false
+ */
+ public ItemAdapter(ItemView itemView) {
+ this(itemView, false);
+ }
+
+ private int pageNumber(int position) {
+ return position / pageSize;
+ }
+
+ /**
+ * Removes all items from this adapter leaving it empty.
+ */
+ public void clear() {
+ count = (mEmptyItem ? 1 : 0);
+ pages.clear();
+ notifyDataSetChanged();
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ T item = getItem(position);
+ if (item != null) {
+ return mItemView.getAdapterView(convertView, parent, position, item, position == selectedIndex);
+ }
+
+ return mItemView.getAdapterView(convertView, parent, position, (position == 0 && mEmptyItem ? "" : loadingText));
+ }
+
+ public ItemListActivity getActivity() {
+ return mItemView.getActivity();
+ }
+
+ public int getSelectedIndex() {
+ return selectedIndex;
+ }
+
+ public void setSelectedIndex(int index) {
+ selectedIndex = index;
+ }
+
+ public void onItemSelected(View view, int position) {
+ T item = getItem(position);
+ if (item != null) {
+ selectedIndex = position;
+ if (mItemView.onItemSelected(view, position, item)) {
+ notifyDataSetChanged();
+ }
+ }
+ }
+ public void onSelected(View view) {
+ mItemView.onGroupSelected(view, getPage(0));
+ }
+ public ItemView getItemView() {
+ return mItemView;
+ }
+
+ public void setItemView(ItemView itemView) {
+ mItemView = itemView;
+ }
+
+ @Override
+ public int getCount() {
+ return count;
+ }
+
+ private T[] getPage(int position) {
+ int pageNumber = pageNumber(position);
+ T[] page = pages.get(pageNumber);
+ if (page == null) {
+ pages.put(pageNumber, page = arrayInstance(pageSize));
+ }
+ return page;
+ }
+
+ private void setItems(int start, List items) {
+ T[] page = getPage(start);
+ int offset = start % pageSize;
+ for (T item : items) {
+ if (mItemView.isSelected(item)) {
+ selectedIndex = start + offset;
+ }
+ if (offset >= pageSize) {
+ start += offset;
+ page = getPage(start);
+ offset = 0;
+ }
+ page[offset++] = item;
+ }
+ }
+
+ @Override
+ public T getItem(int position) {
+ T item = getPage(position)[position % pageSize];
+ if (item == null) {
+ if (mEmptyItem) {
+ position--;
+ }
+ getActivity().maybeOrderPage(pageNumber(position) * pageSize);
+ }
+ return item;
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return position;
+ }
+
+ @Override
+ public boolean isEnabled(int position) {
+ T item = getItem(position);
+ return item != null && mItemView.isSelectable(item);
+ }
+
+ /**
+ * Called when the number of items in the list changes. The default implementation is empty.
+ */
+ protected void onCountUpdated() {
+ }
+
+ /**
+ * Update the contents of the items in this list.
+ *
+ * The size of the list of items is automatically adjusted if necessary, to obey the given
+ * parameters.
+ *
+ * @param count Number of items as reported by SqueezeServer.
+ * @param start The start position of items in this update.
+ * @param items New items to insert in the main list
+ */
+ public void update(int count, int start, List items) {
+ int offset = (mEmptyItem ? 1 : 0);
+ count += offset;
+ start += offset;
+ if (count == 0 || count != getCount()) {
+ this.count = count;
+ onCountUpdated();
+ }
+ setItems(start, items);
+
+ notifyDataSetChanged();
+ }
+
+ /**
+ * @return The position of the given item in this adapter or 0 if not found
+ */
+ public int findItem(T item) {
+ for (int pos = 0; pos < getCount(); pos++) {
+ if (getItem(pos) == null) {
+ if (item == null) {
+ return pos;
+ }
+ } else if (getItem(pos).equals(item)) {
+ return pos;
+ }
+ }
+ return 0;
+ }
+
+ /**
+ * Move the item at the specified position to the new position and notify the change.
+ */
+ public void moveItem(int fromPosition, int toPosition) {
+ T item = getItem(fromPosition);
+ remove(fromPosition);
+ insert(toPosition, item);
+ notifyDataSetChanged();
+ }
+
+ /**
+ * Remove the item at the specified position, update the count and notify the change.
+ */
+ public void removeItem(int position) {
+ remove(position);
+ count--;
+ onCountUpdated();
+ notifyDataSetChanged();
+ }
+
+ /**
+ * Insert an item at the specified position, update the count and notify the change.
+ */
+ public void insertItem(int position, T item) {
+ insert(position, item);
+ count++;
+ onCountUpdated();
+ notifyDataSetChanged();
+ }
+
+ private void remove(int position) {
+ T[] page = getPage(position);
+ int offset = position % pageSize;
+ while (position++ <= count) {
+ if (offset == pageSize - 1) {
+ T[] nextPage = getPage(position);
+ page[offset] = nextPage[0];
+ offset = 0;
+ page = nextPage;
+ } else {
+ page[offset] = page[offset+1];
+ offset++;
+ }
+ }
+
+ }
+
+ private void insert(int position, T item) {
+ int n = count;
+ T[] page = getPage(n);
+ int offset = n % pageSize;
+ while (n-- > position) {
+ if (offset == 0) {
+ T[] nextPage = getPage(n);
+ offset = pageSize - 1;
+ page[0] = nextPage[offset];
+ page = nextPage;
+ } else {
+ page[offset] = page[offset-1];
+ offset--;
+ }
+ }
+ page[offset] = item;
+ }
+
+ private T[] arrayInstance(int size) {
+ return mItemView.getCreator().newArray(size);
+ }
+
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/ItemListActivity.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/ItemListActivity.java
new file mode 100644
index 000000000..a142f4c59
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/ItemListActivity.java
@@ -0,0 +1,408 @@
+/*
+ * Copyright (c) 2011 Kurt Aaholst
+ *
+ * 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 uk.org.ngo.squeezer.framework;
+
+
+import android.os.Bundle;
+import androidx.annotation.MainThread;
+import androidx.annotation.CallSuper;
+import androidx.annotation.NonNull;
+import android.util.Log;
+import android.view.View;
+import android.widget.AbsListView;
+import android.widget.AbsListView.OnScrollListener;
+import android.widget.FrameLayout;
+import android.widget.LinearLayout;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.Stack;
+
+import uk.org.ngo.squeezer.Preferences;
+import uk.org.ngo.squeezer.R;
+import uk.org.ngo.squeezer.itemlist.dialog.ArtworkListLayout;
+import uk.org.ngo.squeezer.model.Item;
+import uk.org.ngo.squeezer.service.ISqueezeService;
+import uk.org.ngo.squeezer.service.SqueezeService;
+import uk.org.ngo.squeezer.service.event.ActivePlayerChanged;
+import uk.org.ngo.squeezer.service.event.HandshakeComplete;
+import uk.org.ngo.squeezer.util.RetainFragment;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+/**
+ * This class defines the common minimum, which any activity browsing the SqueezeServer's database
+ * must implement.
+ *
+ * @author Kurt Aaholst
+ */
+public abstract class ItemListActivity extends BaseActivity {
+
+ private static final String TAG = ItemListActivity.class.getName();
+
+ /**
+ * The list is being actively scrolled by the user
+ */
+ private boolean mListScrolling;
+
+ /**
+ * The number of items per page.
+ */
+ protected int mPageSize;
+
+ /**
+ * The pages that have been requested from the server.
+ */
+ private final Set mOrderedPages = new HashSet<>();
+
+ /**
+ * The pages that have been received from the server
+ */
+ private Set mReceivedPages;
+
+ /**
+ * Pages requested before the handshake completes. A stack on the assumption
+ * that once the service is bound the most recently requested pages should be ordered
+ * first.
+ */
+ private final Stack mOrderedPagesBeforeHandshake = new Stack<>();
+
+ /**
+ * Progress bar (spinning) while items are loading.
+ */
+ private View loadingProgress;
+
+ /**
+ * View to show when no players are connected
+ */
+ private View emptyView;
+
+ /**
+ * Layout hosting the sub activity content
+ */
+ private FrameLayout subActivityContent;
+
+ /**
+ * List view to show the received items
+ */
+ private AbsListView listView;
+
+ /**
+ * Tag for mReceivedPages in mRetainFragment.
+ */
+ private static final String TAG_RECEIVED_PAGES = "mReceivedPages";
+
+ /**
+ * Fragment to retain information across the activity lifecycle.
+ */
+ private RetainFragment mRetainFragment;
+
+ @Override
+ public void setContentView(int layoutResID) {
+ LinearLayout fullLayout = (LinearLayout) getLayoutInflater().inflate(R.layout.item_list_activity_layout, findViewById(R.id.activity_layout));
+ subActivityContent = fullLayout.findViewById(R.id.content_frame);
+ getLayoutInflater().inflate(layoutResID, subActivityContent, true); // Places the activity layout inside the activity content frame.
+ super.setContentView(fullLayout);
+
+ loadingProgress = checkNotNull(findViewById(R.id.loading_label),
+ "activity layout did not return a view containing R.id.loading_label");
+
+ emptyView = checkNotNull(findViewById(R.id.empty_view),
+ "activity layout did not return a view containing R.id.empty_view");
+
+ AbsListView listView = checkNotNull(subActivityContent.findViewById(R.id.item_list),
+ "getContentView() did not return a view containing R.id.item_list");
+ setListView(setupListView(listView));
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ mPageSize = getResources().getInteger(R.integer.PageSize);
+
+ mRetainFragment = RetainFragment.getInstance(TAG, getSupportFragmentManager());
+
+ //noinspection unchecked
+ mReceivedPages = (Set) getRetainedValue(TAG_RECEIVED_PAGES);
+ if (mReceivedPages == null) {
+ mReceivedPages = new HashSet<>();
+ putRetainedValue(TAG_RECEIVED_PAGES, mReceivedPages);
+ }
+ }
+
+ protected Object getRetainedValue(String key) {
+ return mRetainFragment.get(key);
+ }
+
+ protected Object putRetainedValue(String key, Object value) {
+ return mRetainFragment.put(key, value);
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+
+ // Any items coming in after callbacks have been unregistered are discarded.
+ // We cancel any outstanding orders, so items can be reordered after the
+ // activity resumes.
+ cancelOrders();
+ }
+
+ private void showLoading() {
+ subActivityContent.setVisibility(View.GONE);
+ loadingProgress.setVisibility(View.VISIBLE);
+ emptyView.setVisibility(View.GONE);
+ }
+
+ void showEmptyView() {
+ subActivityContent.setVisibility(View.GONE);
+ loadingProgress.setVisibility(View.GONE);
+ emptyView.setVisibility(View.VISIBLE);
+ }
+
+ protected void showContent() {
+ subActivityContent.setVisibility(View.VISIBLE);
+ loadingProgress.setVisibility(View.GONE);
+ emptyView.setVisibility(View.GONE);
+ }
+
+ /**
+ * @return True if the LMS command issued by {@link #orderPage(ISqueezeService, int)} requires a player
+ */
+ protected abstract boolean needPlayer();
+
+ /**
+ * Starts an asynchronous fetch of items from the server. Will only be called after the
+ * service connection has been bound.
+ *
+ * @param service The connection to the bound service.
+ * @param start Position in list to start the fetch. Pass this on to {@link
+ * uk.org.ngo.squeezer.service.SqueezeService}
+ */
+ protected abstract void orderPage(@NonNull ISqueezeService service, int start);
+
+ public ArtworkListLayout getPreferredListLayout() {
+ return new Preferences(this).getAlbumListLayout();
+ }
+
+ /**
+ * Set the list view to host received items
+ */
+ protected abstract AbsListView setupListView(AbsListView listView);
+
+ /**
+ * @return The view listing the items for this acitvity
+ */
+ public final AbsListView getListView() {
+ return listView;
+ }
+
+ public void setListView(AbsListView listView) {
+ this.listView = listView;
+ }
+
+ /**
+ * List can clear any information about which items have been received and ordered, by calling
+ * {@link #clearAndReOrderItems()}. This will call back to this method, which must clear any
+ * adapters holding items.
+ */
+ protected abstract void clearItemAdapter();
+
+ /**
+ * Call back from {@link #onItemsReceived(int, int, List, Class)}
+ */
+ protected abstract void updateAdapter(int count, int start, List items, Class dataType);
+
+ /**
+ * Orders a page worth of data, starting at the specified position, if it has not already been
+ * ordered, and if the service is connected and the handshake has completed.
+ *
+ * @param pagePosition position in the list to start the fetch.
+ * @return True if the page needed to be ordered (even if the order failed), false otherwise.
+ */
+ public boolean maybeOrderPage(int pagePosition) {
+ if (!mListScrolling && !mReceivedPages.contains(pagePosition) && !mOrderedPages
+ .contains(pagePosition) && !mOrderedPagesBeforeHandshake.contains(pagePosition)) {
+ ISqueezeService service = getService();
+
+ // If the service connection hasn't happened yet then store the page
+ // request where it can be used in mHandshakeComplete.
+ if (service == null) {
+ mOrderedPagesBeforeHandshake.push(pagePosition);
+ } else {
+ try {
+ orderPage(service, pagePosition);
+ mOrderedPages.add(pagePosition);
+ } catch (SqueezeService.HandshakeNotCompleteException e) {
+ mOrderedPagesBeforeHandshake.push(pagePosition);
+ }
+ }
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Update the UI with the player change
+ */
+ @MainThread
+ public void onEventMainThread(ActivePlayerChanged event) {
+ Log.i(TAG, "ActivePlayerChanged: " + event.player);
+ supportInvalidateOptionsMenu();
+ if (needPlayer()) {
+ if (event.player == null) {
+ showEmptyView();
+ } else {
+ clearAndReOrderItems();
+ }
+ }
+ }
+
+ /**
+ * Orders any pages requested before the handshake completed.
+ */
+ @MainThread
+ public void onEventMainThread(HandshakeComplete event) {
+ // Order any pages that were requested before the handshake complete.
+ while (!mOrderedPagesBeforeHandshake.empty()) {
+ maybeOrderPage(mOrderedPagesBeforeHandshake.pop());
+ }
+ }
+
+ /**
+ * Orders pages that correspond to visible rows in the listview.
+ *
+ * Computes the pages that correspond to the rows that are currently being displayed by the
+ * listview, and calls {@link #maybeOrderPage(int)} to fetch the page if necessary.
+ *
+ * @param listView The listview with visible rows.
+ */
+ public void maybeOrderVisiblePages(AbsListView listView) {
+ int pos = (listView.getFirstVisiblePosition() / mPageSize) * mPageSize;
+ int end = listView.getFirstVisiblePosition() + listView.getChildCount();
+
+ while (pos <= end) {
+ maybeOrderPage(pos);
+ pos += mPageSize;
+ }
+ }
+
+ /**
+ * Tracks items that have been received from the server.
+ *
+ * Subclasses must call this method when receiving data from the server to ensure that
+ * internal bookkeeping about pages that have/have not been ordered is kept consistent.
+ *
+ * This will call back to {@link #updateAdapter(int, int, List, Class)} on the UI thread
+ *
+ * @param count The total number of items known by the server.
+ * @param start The start position of this update.
+ * @param items The items received in this update
+ */
+ @CallSuper
+ protected void onItemsReceived(final int count, final int start, final List items, final Class dataType) {
+ int size = items.size();
+ Log.d(TAG, "onItemsReceived(" + count + ", " + start + ", " + size + ")");
+
+ // If this doesn't add any items, then don't register the page as received
+ if (start < count && size != 0) {
+ // Because we might receive a page in chunks, we test if this is the end of a page
+ // before we register the page as received.
+ if (((start + size) % mPageSize == 0) || (start + size == count)) {
+ // Add this page of data to mReceivedPages and remove from mOrderedPages.
+ int pageStart = (start / mPageSize) * mPageSize;
+ mReceivedPages.add(pageStart);
+ mOrderedPages.remove(pageStart);
+ }
+ }
+
+ runOnUiThread(() -> {
+ showContent();
+ updateAdapter(count, start, items, dataType);
+ });
+ }
+
+ /**
+ * Empties the variables that track which pages have been requested, and orders page 0.
+ */
+ public void clearAndReOrderItems() {
+ if (!(needPlayer() && getService().getActivePlayer() == null)) {
+ showLoading();
+ clearItems();
+ maybeOrderPage(0);
+ }
+ }
+
+ /** Empty the variables that track which pages have been requested. */
+ public void clearItems() {
+ mOrderedPagesBeforeHandshake.clear();
+ mOrderedPages.clear();
+ mReceivedPages.clear();
+ clearItemAdapter();
+ }
+
+ /**
+ * Removes any outstanding requests from mOrderedPages.
+ */
+ private void cancelOrders() {
+ mOrderedPages.clear();
+ }
+
+ /**
+ * Tracks scrolling activity.
+ *
+ * When the list is idle, new pages of data are fetched from the server.
+ */
+ protected class ScrollListener implements AbsListView.OnScrollListener {
+ private int mPrevScrollState = OnScrollListener.SCROLL_STATE_IDLE;
+
+ public ScrollListener() {
+ }
+
+ @Override
+ public void onScrollStateChanged(AbsListView listView, int scrollState) {
+ if (scrollState == mPrevScrollState) {
+ return;
+ }
+
+ switch (scrollState) {
+ case OnScrollListener.SCROLL_STATE_IDLE:
+ mListScrolling = false;
+ maybeOrderVisiblePages(listView);
+ break;
+
+ case OnScrollListener.SCROLL_STATE_FLING:
+ case OnScrollListener.SCROLL_STATE_TOUCH_SCROLL:
+ mListScrolling = true;
+ break;
+ }
+
+ mPrevScrollState = scrollState;
+ }
+
+ // Do not use: is not called when the scroll completes, appears to be
+ // called multiple time during a scroll, including during flinging.
+ @Override
+ public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount,
+ int totalItemCount) {
+ }
+
+ }
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/ItemView.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/ItemView.java
new file mode 100644
index 000000000..cef31d40e
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/ItemView.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright (c) 2011 Kurt Aaholst
+ *
+ * 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 uk.org.ngo.squeezer.framework;
+
+import android.os.Parcelable.Creator;
+import android.view.View;
+import android.view.ViewGroup;
+
+import uk.org.ngo.squeezer.model.Item;
+
+
+/**
+ * Defines view logic for a {@link Item}
+ *
+ * We keep this here because we don't want to pollute the model with view related stuff.
+ *
+ * Currently this is the only logic class you have to implement for each SqueezeServer data type, so
+ * it contains a few methods, which are not strictly view related.
+ *
+ * {@link BaseItemView} implements all the common functionality, an some sensible defaults.
+ *
+ * @param Denotes the class of the item this class implements view logic for
+ *
+ * @author Kurt Aaholst
+ */
+public interface ItemView {
+
+ /**
+ * @return The activity associated with this view logic
+ */
+ ItemListActivity getActivity();
+
+ /**
+ * Gets a {@link android.view.View} that displays the data at the specified position in the data
+ * set. See {@link ItemAdapter#getView(int, View, android.view.ViewGroup)}
+ *
+ * @param convertView the old view to reuse, per {@link android.widget.Adapter#getView(int, View,
+ * android.view.ViewGroup)}
+ * @param position Position of item in adapter
+ * @param item the item to display.
+ *
+ * @return the view to display.
+ */
+ View getAdapterView(View convertView, ViewGroup parent, int position, T item, boolean selected);
+
+ /**
+ * Gets a {@link android.view.View} suitable for displaying the supplied (static) text. See
+ * {@link ItemAdapter#getView(int, View, android.view.ViewGroup)}
+ *
+ * @param convertView The old view to reuse, per {@link android.widget.Adapter#getView(int,
+ * View, android.view.ViewGroup)}
+ * @param text text to display
+ *
+ * @return the view to display.
+ */
+ View getAdapterView(View convertView, ViewGroup parent, int position, String text);
+
+ /**
+ * @return The generic argument of the implementation
+ */
+ Class getItemClass();
+
+ /**
+ * @return the creator for the current {@link Item} implementation
+ */
+ Creator getCreator();
+
+ /**
+ * Return whether the supplied item shall be selectable in a list
+ *
+ * @param item Item to check
+ * @return True if the item is selectable
+ * @see android.widget.ListAdapter#isEnabled(int)
+ */
+ boolean isSelectable(T item);
+
+ /**
+ * Return whether the supplied item is currently selected
+ * @param item Item to check
+ * @return True if the item is selected
+ */
+ boolean isSelected(T item);
+
+ /**
+ * Implement the action to be taken when an item is selected.
+ *
+ * @param view The view currently showing the item.
+ * @param index Position in the list of the selected item.
+ * @param item The selected item. This may be null if
+ * @return True if {@link android.widget.BaseAdapter#notifyDataSetChanged()} shall be called
+ */
+ boolean onItemSelected(View view, int index, T item);
+
+ /**
+ * Implement the action to be taken when an item is selected.
+ *
+ * @param view The view currently showing the item.
+ */
+ void onGroupSelected(View view, T[] items);
+
+ /**
+ * Creates the context menu.
+ *
+ * The default implementation is empty.
+ *
+ * Subclasses with a context menu should override this method, create a
+ * {@link android.widget.PopupMenu} or a {@link android.app.Dialog} then
+ * inflate their context menu and show it.
+ *
+ */
+ void showContextMenu(BaseItemView.ViewHolder v, T item);
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/ContextServicePlayerHandler.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/ContextServicePlayerHandler.java
new file mode 100644
index 000000000..cb266088b
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/ContextServicePlayerHandler.java
@@ -0,0 +1,11 @@
+package uk.org.ngo.squeezer.homescreenwidgets;
+
+import android.content.Context;
+
+import uk.org.ngo.squeezer.model.Player;
+import uk.org.ngo.squeezer.service.ISqueezeService;
+
+@FunctionalInterface
+interface ContextServicePlayerHandler {
+ void run(Context context, ISqueezeService service, Player player) throws Exception;
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/RemoteButton.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/RemoteButton.java
new file mode 100644
index 000000000..1381ec759
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/RemoteButton.java
@@ -0,0 +1,102 @@
+package uk.org.ngo.squeezer.homescreenwidgets;
+
+import android.os.Handler;
+import android.provider.Settings;
+
+import androidx.annotation.DrawableRes;
+import androidx.annotation.StringRes;
+
+import uk.org.ngo.squeezer.NowPlayingActivity;
+import uk.org.ngo.squeezer.R;
+import uk.org.ngo.squeezer.itemlist.CurrentPlaylistActivity;
+import uk.org.ngo.squeezer.itemlist.HomeActivity;
+import uk.org.ngo.squeezer.service.IRButton;
+import uk.org.ngo.squeezer.service.ISqueezeService;
+
+public enum RemoteButton {
+ OPEN((context, service, player) -> {
+ service.setActivePlayer(service.getPlayer(player.getId()));
+ Handler handler = new Handler();
+ float animationDelay = Settings.Global.getFloat(context.getContentResolver(),
+ Settings.Global.ANIMATOR_DURATION_SCALE, 1.0f);
+ handler.postDelayed(() -> HomeActivity.show(context), (long) (300 * animationDelay));
+ }, R.string.remote_openPlayer, R.drawable.ic_home),
+ OPEN_NOW_PLAYING((context, service, player) -> {
+ service.setActivePlayer(service.getPlayer(player.getId()));
+ Handler handler = new Handler();
+ float animationDelay = Settings.Global.getFloat(context.getContentResolver(),
+ Settings.Global.ANIMATOR_DURATION_SCALE, 1.0f);
+ handler.postDelayed(() -> NowPlayingActivity.show(context), (long) (300 * animationDelay));
+ }, R.string.remote_openNowPlaying, R.drawable.ic_action_nowplaying),
+ OPEN_CURRENT_PLAYLIST((context, service, player) -> {
+ service.setActivePlayer(service.getPlayer(player.getId()));
+ Handler handler = new Handler();
+ float animationDelay = Settings.Global.getFloat(context.getContentResolver(),
+ Settings.Global.ANIMATOR_DURATION_SCALE, 1.0f);
+ handler.postDelayed(() -> CurrentPlaylistActivity.show(context), (long) (300 * animationDelay));
+ }, R.string.remote_openCurrentPlaylist, R.drawable.ic_action_playlist),
+ POWER(ISqueezeService::togglePower, R.string.remote_powerDescription, R.drawable.ic_action_power_settings_new),
+ NEXT(ISqueezeService::nextTrack, R.string.remote_nextDescription, R.drawable.ic_action_next),
+ PREVIOUS(ISqueezeService::previousTrack, R.string.remote_previousDescription, R.drawable.ic_action_previous),
+ PLAY(ISqueezeService::togglePausePlay, R.string.remote_pausePlayDescription, R.drawable.ic_action_play),
+ PRESET_1((context, service, player) -> service.button(player, IRButton.playPreset_1), R.string.remote_preset1Description, "1"),
+ PRESET_2((context, service, player) -> service.button(player, IRButton.playPreset_2), R.string.remote_preset2Description, "2"),
+ PRESET_3((context, service, player) -> service.button(player, IRButton.playPreset_3), R.string.remote_preset3Description, "3"),
+ PRESET_4((context, service, player) -> service.button(player, IRButton.playPreset_4), R.string.remote_preset4Description, "4"),
+ PRESET_5((context, service, player) -> service.button(player, IRButton.playPreset_5), R.string.remote_preset5Description, "5"),
+ PRESET_6((context, service, player) -> service.button(player, IRButton.playPreset_6), R.string.remote_preset6Description, "6"),
+
+ // Must be last since it's truncated from user-visible lists
+ UNKNOWN((context, service, player) -> {
+ }, R.string.remote_unknownDescription, "?"),
+ ;
+
+
+ public static final int UNKNOWN_IMAGE = -1;
+ private ContextServicePlayerHandler handler;
+ private @DrawableRes
+ int buttonImage = UNKNOWN_IMAGE;
+
+ private @StringRes
+ int description;
+ private String buttonText;
+
+ RemoteButton(ContextServicePlayerHandler handler, @StringRes int description) {
+ this(handler, description, UNKNOWN_IMAGE);
+ }
+
+ RemoteButton(ServicePlayerHandler handler, @StringRes int description, @DrawableRes int buttonImage) {
+ this((context, service, player) -> handler.run(service, player), description, buttonImage);
+ }
+
+ RemoteButton(ContextServicePlayerHandler handler, @StringRes int description, @DrawableRes int buttonImage) {
+ this.handler = handler;
+ this.buttonImage = buttonImage;
+ this.description = description;
+ }
+
+ RemoteButton(ContextServicePlayerHandler handler, @StringRes int description, String buttonText) {
+ this.handler = handler;
+ this.buttonText = buttonText;
+ this.description = description;
+ }
+
+ public ContextServicePlayerHandler getHandler() {
+ return handler;
+ }
+
+ public int getButtonImage() {
+ return buttonImage;
+ }
+
+ public String getButtonText() {
+ return buttonText;
+ }
+
+ public @StringRes
+ int getDescription() {
+ return description;
+ }
+
+
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/ServiceHandler.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/ServiceHandler.java
new file mode 100644
index 000000000..4c1633291
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/ServiceHandler.java
@@ -0,0 +1,8 @@
+package uk.org.ngo.squeezer.homescreenwidgets;
+
+import uk.org.ngo.squeezer.service.ISqueezeService;
+
+@FunctionalInterface
+interface ServiceHandler {
+ void run(ISqueezeService service) throws Exception;
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/ServicePlayerHandler.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/ServicePlayerHandler.java
new file mode 100644
index 000000000..224d0729f
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/ServicePlayerHandler.java
@@ -0,0 +1,9 @@
+package uk.org.ngo.squeezer.homescreenwidgets;
+
+import uk.org.ngo.squeezer.model.Player;
+import uk.org.ngo.squeezer.service.ISqueezeService;
+
+@FunctionalInterface
+interface ServicePlayerHandler {
+ void run(ISqueezeService service, Player player) throws Exception;
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/SqueezerHomeScreenWidget.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/SqueezerHomeScreenWidget.java
new file mode 100644
index 000000000..1c82f35ce
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/SqueezerHomeScreenWidget.java
@@ -0,0 +1,102 @@
+package uk.org.ngo.squeezer.homescreenwidgets;
+
+import android.appwidget.AppWidgetProvider;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Looper;
+import android.util.Log;
+import android.widget.Toast;
+
+import androidx.annotation.Nullable;
+
+import uk.org.ngo.squeezer.service.ISqueezeService;
+import uk.org.ngo.squeezer.service.SqueezeService;
+import uk.org.ngo.squeezer.service.event.PlayersChanged;
+
+public class SqueezerHomeScreenWidget extends AppWidgetProvider {
+
+ private static final String TAG = SqueezerHomeScreenWidget.class.getName();
+
+ public static final String PLAYER_ID = "playerId";
+
+ private final Handler uiThreadHandler = new Handler(Looper.getMainLooper());
+
+ /**
+ * Returns number of cells needed for given size of the widget.
+ *
+ * @param size Widget size in dp.
+ * @return Size in number of cells.
+ */
+ protected static int getCellsForSize(int size) {
+ int n = 2;
+ while (70 * n - 30 < size) {
+ ++n;
+ }
+ return n - 1;
+ }
+
+ protected void runOnService(final Context context, final ServiceHandler handler) {
+ boolean bound = context.getApplicationContext().bindService(new Intent(context, SqueezeService.class), new ServiceConnection() {
+ public void onServiceConnected(ComponentName name, IBinder service1) {
+ final ServiceConnection serviceConnection = this;
+
+ if (name != null && service1 instanceof ISqueezeService) {
+ Log.i(SqueezerHomeScreenWidget.TAG, "onServiceConnected connected to ISqueezeService");
+ final ISqueezeService squeezeService = (ISqueezeService) service1;
+
+ // Wait for the PlayersChanged event
+ squeezeService.getEventBus().registerSticky(new Object() {
+ public void onEvent(PlayersChanged event) {
+ squeezeService.getEventBus().unregister(this);
+ Log.i(SqueezerHomeScreenWidget.TAG, "Players ready, perform action");
+ uiThreadHandler.post(() -> {
+ showToastExceptionIfExists(context, runHandlerAndCatchException(handler, squeezeService));
+ // Handler was called successfully; service no longer needed
+ context.unbindService(serviceConnection);
+ });
+ }
+ });
+
+ // Auto connect if necessary
+ if (!squeezeService.isConnected()) {
+ Log.i(SqueezerHomeScreenWidget.TAG, "SqueezeService wasn't connected, connecting...");
+ squeezeService.startConnect();
+ }
+ }
+ }
+
+ public void onServiceDisconnected(ComponentName name) {
+ Log.i(SqueezerHomeScreenWidget.TAG, "service disconnected");
+ }
+ }, Context.BIND_AUTO_CREATE);
+
+ if (!bound)
+ Log.e(SqueezerHomeScreenWidget.TAG, "Squeezer service not bound");
+ }
+
+ protected void showToastExceptionIfExists(Context context, @Nullable Exception possibleException) {
+ if (possibleException != null) {
+ Toast.makeText(context, possibleException.getMessage(), Toast.LENGTH_LONG).show();
+ }
+ }
+
+ private @Nullable
+ Exception runHandlerAndCatchException(ServiceHandler handler, ISqueezeService squeezeService) {
+ try {
+ handler.run(squeezeService);
+ return null;
+ } catch (Exception ex) {
+ Log.e(SqueezerHomeScreenWidget.TAG, "Exception while handling serviceHandler", ex);
+ return ex;
+ }
+ }
+
+ protected void runOnPlayer(final Context context, final String playerId, final ContextServicePlayerHandler handler) {
+ runOnService(context, service -> handler.run(context, service, service.getPlayer(playerId)));
+ }
+
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/SqueezerInfoScreen.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/SqueezerInfoScreen.java
new file mode 100644
index 000000000..4d1a6e46d
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/SqueezerInfoScreen.java
@@ -0,0 +1,130 @@
+package uk.org.ngo.squeezer.homescreenwidgets;
+
+import android.annotation.TargetApi;
+import android.app.PendingIntent;
+import android.appwidget.AppWidgetManager;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.provider.Settings;
+import android.util.Log;
+import android.widget.RemoteViews;
+
+import uk.org.ngo.squeezer.R;
+import uk.org.ngo.squeezer.itemlist.HomeActivity;
+
+/**
+ * TODO this will eventually be a player status widget but is currently WIP
+ * App Widget Configuration implemented in {@link SqueezerRemoteControlPlayerSelectActivity SqueezerRemoteControlConfigureActivity}
+ */
+public class SqueezerInfoScreen extends SqueezerHomeScreenWidget {
+
+ private static final String TAG = SqueezerInfoScreen.class.getName();
+
+
+ private static final String SQUEEZER_REMOTE_OPEN = "squeezeRemoteOpen";
+
+
+ static void updateAppWidget(Context context, AppWidgetManager appWidgetManager,
+ int appWidgetId) {
+
+ String playerId = SqueezerRemoteControl.loadPlayerId(context, appWidgetId);
+ String playerName = SqueezerRemoteControl.loadPlayerName(context, appWidgetId);
+
+ // Construct the RemoteViews object
+
+ // See the dimensions and
+ Bundle options = appWidgetManager.getAppWidgetOptions(appWidgetId);
+
+ // Get min width and height.
+ int minWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH);
+ int minHeight = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT);
+ RemoteViews views = getRemoteViews(context, minWidth, minHeight);
+ appWidgetManager.updateAppWidget(appWidgetId, views);
+
+ Log.d(TAG, "wiring up widget for player " + playerName + " with id " + playerId);
+ views.setTextViewText(R.id.squeezerRemote_playerButton, playerName);
+ views.setOnClickPendingIntent(R.id.squeezerRemote_playerButton, getPendingSelfIntent(context, SQUEEZER_REMOTE_OPEN, playerId));
+
+ // Instruct the widget manager to update the widget
+ appWidgetManager.updateAppWidget(appWidgetId, views);
+ }
+
+ static PendingIntent getPendingSelfIntent(Context context, String action, String playerId) {
+ Intent intent = new Intent(context, SqueezerInfoScreen.class);
+ intent.setAction(action);
+ intent.putExtra(PLAYER_ID, playerId);
+
+ return PendingIntent.getBroadcast(context, playerId.hashCode(), intent, PendingIntent.FLAG_UPDATE_CURRENT);
+ }
+
+ @Override
+ public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
+ // There may be multiple widgets active, so update all of them
+ for (int appWidgetId : appWidgetIds) {
+ updateAppWidget(context, appWidgetManager, appWidgetId);
+ }
+ }
+
+ @TargetApi(Build.VERSION_CODES.JELLY_BEAN)
+ @Override
+ public void onAppWidgetOptionsChanged(Context context, AppWidgetManager appWidgetManager, int appWidgetId, Bundle newOptions) {
+
+ updateAppWidget(context, appWidgetManager, appWidgetId);
+ super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions);
+ }
+
+ /**
+ * Determine appropriate view based on row or column provided.
+ *
+ * @param minWidth
+ * @param minHeight
+ * @return
+ */
+ private static RemoteViews getRemoteViews(Context context, int minWidth, int minHeight) {
+ // First find out rows and columns based on width provided.
+ int rows = getCellsForSize(minHeight);
+ int columns = getCellsForSize(minWidth);
+ // Now you changing layout base on you column count
+ // In this code from 1 column to 4
+ // you can make code for more columns on your own.
+ switch (columns) {
+ default:
+ return new RemoteViews(context.getPackageName(), R.layout.squeezer_remote_control);
+ }
+ }
+
+ public void onReceive(final Context context, Intent intent) {
+ super.onReceive(context, intent);
+ String action = intent.getAction();
+ final String playerId = intent.getStringExtra(PLAYER_ID);
+
+
+ Log.d(TAG, "recieved intent with action " + action + " and playerid " + playerId);
+
+ if (SQUEEZER_REMOTE_OPEN.equals(action)) {
+ runOnService(context, service -> {
+ Log.d(TAG, "setting active player: " + playerId);
+ service.setActivePlayer(service.getPlayer(playerId));
+ Handler handler = new Handler();
+ float animationDelay = Settings.Global.getFloat(context.getContentResolver(),
+ Settings.Global.ANIMATOR_DURATION_SCALE, 1.0f);
+ handler.postDelayed(() -> HomeActivity.show(context), (long) (300 * animationDelay));
+ });
+
+ }
+ }
+
+
+ @Override
+ public void onDeleted(Context context, int[] appWidgetIds) {
+ // When the user deletes the widget, delete the preference associated with it.
+ for (int appWidgetId : appWidgetIds) {
+ SqueezerRemoteControl.deletePrefs(context, appWidgetId);
+ }
+ }
+
+}
+
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/SqueezerRemoteControl.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/SqueezerRemoteControl.java
new file mode 100644
index 000000000..c4289b237
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/SqueezerRemoteControl.java
@@ -0,0 +1,164 @@
+package uk.org.ngo.squeezer.homescreenwidgets;
+
+import android.annotation.TargetApi;
+import android.app.PendingIntent;
+import android.appwidget.AppWidgetManager;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.os.Build;
+import android.os.Bundle;
+import android.util.Log;
+import android.view.View;
+import android.widget.RemoteViews;
+
+import uk.org.ngo.squeezer.R;
+import uk.org.ngo.squeezer.Util;
+import uk.org.ngo.squeezer.model.Player;
+
+
+/**
+ * Implementation of App Widget functionality.
+ * App Widget Configuration implemented in {@link SqueezerRemoteControlPlayerSelectActivity SqueezerRemoteControlConfigureActivity}
+ */
+public class SqueezerRemoteControl extends SqueezerHomeScreenWidget {
+
+ public static final String UNKNOWN_PLAYER = "UNKNOWN_PLAYER";
+ private static final String ACTION_PREFIX = "ngo.squeezer.homescreenwidgets.";
+ private static final String TAG = SqueezerRemoteControl.class.getName();
+ static final String PREFS_NAME = "uk.org.ngo.squeezer.homescreenwidgets.SqueezerRemoteControl";
+ static final String PREF_PREFIX_KEY = "squeezerRemote_";
+ static final String PREF_SUFFIX_PLAYER_ID = "playerId";
+ static final String PREF_SUFFIX_PLAYER_NAME = "playerName";
+ static final String PREF_SUFFIX_BUTTON = "button";
+
+ static final String EXTRA_PLAYER = "player";
+ static final String EXTRA_REMOTE_BUTTON = "remoteButton";
+
+
+ static void updateAppWidget(Context context, AppWidgetManager appWidgetManager,
+ int appWidgetId) {
+
+ String playerId = loadPlayerId(context, appWidgetId);
+ String playerName = loadPlayerName(context, appWidgetId);
+ String action = loadAction(context, appWidgetId);
+ RemoteButton button = RemoteButton.valueOf(action);
+
+ // Construct the RemoteViews object
+ RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.squeezer_remote_control);
+ appWidgetManager.updateAppWidget(appWidgetId, views);
+
+ Log.d(TAG, "wiring up widget for player " + playerName + " with id " + playerId);
+ views.setTextViewText(R.id.squeezerRemote_playerButton, playerName);
+ views.setOnClickPendingIntent(R.id.squeezerRemote_playerButton, getPendingSelfIntent(context, RemoteButton.OPEN, playerId));
+
+
+ int buttonId;
+ if (button.getButtonImage() != RemoteButton.UNKNOWN_IMAGE) {
+ buttonId = R.id.squeezerRemote_imageButton;
+ views.setImageViewBitmap(buttonId, Util.vectorToBitmap(context, button.getButtonImage()));
+ } else {
+ buttonId = R.id.squeezerRemote_textButton;
+ views.setTextViewText(buttonId, button.getButtonText());
+ }
+ views.setViewVisibility(buttonId, View.VISIBLE);
+ views.setContentDescription(buttonId, context.getString(button.getDescription()));
+ views.setOnClickPendingIntent(buttonId, getPendingSelfIntent(context, button, playerId));
+
+
+ // Instruct the widget manager to update the widget
+ appWidgetManager.updateAppWidget(appWidgetId, views);
+ }
+
+ static PendingIntent getPendingSelfIntent(Context context, RemoteButton button, String playerId) {
+ Intent intent = new Intent(context, SqueezerRemoteControl.class);
+ intent.setAction(ACTION_PREFIX + button.name());
+ intent.putExtra(PLAYER_ID, playerId);
+
+ return PendingIntent.getBroadcast(context, playerId.hashCode(), intent, PendingIntent.FLAG_UPDATE_CURRENT);
+ }
+
+ // Read the prefix from the SharedPreferences object for this widget.
+ // If there is no preference saved, get the default from a resource
+ static String loadPlayerId(Context context, int appWidgetId) {
+ SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, 0);
+ return prefs.getString(PREF_PREFIX_KEY + appWidgetId + PREF_SUFFIX_PLAYER_ID, UNKNOWN_PLAYER);
+ }
+
+ static String loadPlayerName(Context context, int appWidgetId) {
+ SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, 0);
+ return prefs.getString(PREF_PREFIX_KEY + appWidgetId + PREF_SUFFIX_PLAYER_NAME, UNKNOWN_PLAYER);
+ }
+
+ static String loadAction(Context context, int appWidgetId) {
+ SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, 0);
+ return prefs.getString(PREF_PREFIX_KEY + appWidgetId + PREF_SUFFIX_BUTTON, RemoteButton.UNKNOWN.name());
+ }
+
+ static void deletePrefs(Context context, int appWidgetId) {
+ SharedPreferences.Editor prefs = context.getSharedPreferences(PREFS_NAME, 0).edit();
+ prefs.remove(PREF_PREFIX_KEY + appWidgetId + PREF_SUFFIX_PLAYER_ID);
+ prefs.remove(PREF_PREFIX_KEY + appWidgetId + PREF_SUFFIX_PLAYER_NAME);
+ prefs.remove(PREF_PREFIX_KEY + appWidgetId + PREF_SUFFIX_BUTTON);
+ prefs.apply();
+ }
+
+ public static void savePrefs(Context context, Intent intent) {
+ SharedPreferences.Editor prefs = context.getSharedPreferences(SqueezerRemoteControl.PREFS_NAME, Context.MODE_PRIVATE).edit();
+
+ Player player = intent.getParcelableExtra(EXTRA_PLAYER);
+ RemoteButton button = (RemoteButton) intent.getSerializableExtra(EXTRA_REMOTE_BUTTON);
+
+ int widgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID);
+
+ AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context);
+
+ prefs.putString(SqueezerRemoteControl.PREF_PREFIX_KEY + widgetId + SqueezerRemoteControl.PREF_SUFFIX_PLAYER_ID, player.getId());
+ prefs.putString(SqueezerRemoteControl.PREF_PREFIX_KEY + widgetId + SqueezerRemoteControl.PREF_SUFFIX_PLAYER_NAME, player.getName());
+ prefs.putString(SqueezerRemoteControl.PREF_PREFIX_KEY + widgetId + SqueezerRemoteControl.PREF_SUFFIX_BUTTON, button.name());
+ prefs.apply();
+
+ SqueezerRemoteControl.updateAppWidget(context, appWidgetManager, widgetId);
+
+ }
+
+ @Override
+ public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
+ // There may be multiple widgets active, so update all of them
+ for (int appWidgetId : appWidgetIds) {
+ updateAppWidget(context, appWidgetManager, appWidgetId);
+ }
+ }
+
+ @TargetApi(Build.VERSION_CODES.JELLY_BEAN)
+ @Override
+ public void onAppWidgetOptionsChanged(Context context, AppWidgetManager appWidgetManager, int appWidgetId, Bundle newOptions) {
+
+ updateAppWidget(context, appWidgetManager, appWidgetId);
+ super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions);
+ }
+
+ public void onReceive(final Context context, Intent intent) {
+ super.onReceive(context, intent);
+ String action = intent.getAction();
+ final String playerId = intent.getStringExtra(PLAYER_ID);
+
+ Log.d(TAG, "recieved intent with action " + action + " and playerid " + playerId);
+
+ if (action.startsWith(ACTION_PREFIX)) {
+ RemoteButton button = RemoteButton.valueOf(action.substring(ACTION_PREFIX.length()));
+ runOnPlayer(context, playerId, button.getHandler());
+ }
+ }
+
+
+ @Override
+ public void onDeleted(Context context, int[] appWidgetIds) {
+ // When the user deletes the widget, delete the preference associated with it.
+ for (int appWidgetId : appWidgetIds) {
+ deletePrefs(context, appWidgetId);
+ }
+ }
+
+}
+
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/SqueezerRemoteControlButtonSelectActivity.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/SqueezerRemoteControlButtonSelectActivity.java
new file mode 100644
index 000000000..f4cce9fc6
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/SqueezerRemoteControlButtonSelectActivity.java
@@ -0,0 +1,163 @@
+package uk.org.ngo.squeezer.homescreenwidgets;
+
+import android.appwidget.AppWidgetManager;
+import android.content.Intent;
+import android.content.res.Resources;
+import android.content.res.TypedArray;
+import android.graphics.Color;
+import android.os.Bundle;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.ActionBar;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import java.util.Arrays;
+import java.util.function.Consumer;
+
+import uk.org.ngo.squeezer.R;
+import uk.org.ngo.squeezer.Util;
+import uk.org.ngo.squeezer.framework.BaseActivity;
+import uk.org.ngo.squeezer.model.Player;
+
+public class SqueezerRemoteControlButtonSelectActivity extends BaseActivity {
+
+ private static final String TAG = SqueezerRemoteControlButtonSelectActivity.class.getName();
+
+ RecyclerView remoteButtonListView;
+ RemoteButton[] remoteButtonListItems = RemoteButton.values();
+ ItemAdapter remoteButtonListAdapter;
+
+
+ private int mAppWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID;
+ private Player player;
+
+ /*
+ This Activity leverages a base Activity which almost all of squeezer uses, itself adding an
+ actionBar, which we don't want on this activity.
+ */
+ protected boolean addActionBar() {
+ return false;
+ }
+
+ @Override
+ public void onCreate(Bundle icicle) {
+ super.onCreate(icicle);
+
+ Log.d(TAG, "onCreate");
+
+ setResult(RESULT_CANCELED);
+ ActionBar actionBar = getSupportActionBar();
+ if (actionBar != null) {
+ actionBar.setTitle(R.string.configure_select_button);
+ }
+
+ setContentView(R.layout.squeezer_remote_control_button_select);
+
+
+ // Find the widget id from the intent.
+ Intent intent = getIntent();
+ Bundle extras = intent.getExtras();
+ if (extras != null) {
+ mAppWidgetId = extras.getInt(
+ AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID);
+ player = extras.getParcelable(SqueezerRemoteControl.EXTRA_PLAYER);
+ }
+
+ // If this activity was started with an intent without an app widget ID, finish with an error.
+ if (mAppWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
+ finish();
+ return;
+ }
+
+ remoteButtonListView = findViewById(R.id.remoteButtonList);
+ remoteButtonListView.setLayoutManager(new LinearLayoutManager(this));
+ remoteButtonListAdapter = new ItemAdapter(
+ Arrays.stream(remoteButtonListItems).filter(b -> b != RemoteButton.UNKNOWN).toArray(RemoteButton[]::new),
+ this::finish);
+
+ remoteButtonListView.setAdapter(remoteButtonListAdapter);
+
+
+ }
+
+ private class ItemAdapter extends RecyclerView.Adapter {
+
+ private RemoteButton[] buttons;
+ private Consumer clickHandler;
+
+ public ItemAdapter(RemoteButton[] buttons, Consumer clickHandler) {
+ this.buttons = buttons;
+ this.clickHandler = clickHandler;
+ }
+
+ public int getItemCount() {
+ return buttons.length;
+ }
+
+ @Override
+ @NonNull
+ public RecyclerView.ViewHolder onCreateViewHolder(final @NonNull ViewGroup parent, final int viewType) {
+ return new RemoteButtonViewHolder(LayoutInflater.from(parent.getContext()).inflate(viewType, parent, false), clickHandler);
+ }
+
+ @Override
+ public void onBindViewHolder(final @NonNull RecyclerView.ViewHolder holder, final int position) {
+ RemoteButtonViewHolder viewHolder = (RemoteButtonViewHolder) holder;
+ viewHolder.bindData(buttons[position]);
+ }
+
+ @Override
+ public int getItemViewType(final int position) {
+ return R.layout.squeezer_remote_control_button_select_item;
+ }
+ }
+
+ private class RemoteButtonViewHolder extends RecyclerView.ViewHolder {
+ private TextView textView;
+ private ImageView imageView;
+ private Consumer clickHandler;
+
+ public RemoteButtonViewHolder(final View itemView, Consumer clickHandler) {
+ super(itemView);
+ textView = itemView.findViewById(R.id.text);
+ imageView = itemView.findViewById(R.id.icon);
+ this.clickHandler = clickHandler;
+ }
+
+ public void bindData(final RemoteButton button) {
+ itemView.setOnClickListener(v -> clickHandler.accept(button));
+ int buttonImage = button.getButtonImage();
+ int description = button.getDescription();
+
+ if (buttonImage != RemoteButton.UNKNOWN_IMAGE) {
+ imageView.setImageBitmap(Util.vectorToBitmap(SqueezerRemoteControlButtonSelectActivity.this.getBaseContext(), buttonImage));
+ } else {
+ final TypedArray a = obtainStyledAttributes(new int[]{R.attr.colorControlNormal});
+ final int tintColor = a.getColor(0, 0);
+ a.recycle();
+
+ TextDrawable drawable = new TextDrawable(Resources.getSystem(), button.getButtonText(), tintColor);
+ imageView.setImageDrawable(drawable);
+ }
+ textView.setText(description);
+ }
+ }
+
+
+ public void finish(RemoteButton button) {
+ // Make sure we pass back the original appWidgetId
+ Intent resultValue = new Intent();
+ resultValue.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, mAppWidgetId);
+ resultValue.putExtra(SqueezerRemoteControl.EXTRA_PLAYER, player);
+ resultValue.putExtra(SqueezerRemoteControl.EXTRA_REMOTE_BUTTON, button);
+ setResult(RESULT_OK, resultValue);
+ finish();
+ }
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/SqueezerRemoteControlPlayerSelectActivity.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/SqueezerRemoteControlPlayerSelectActivity.java
new file mode 100644
index 000000000..9849884c8
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/SqueezerRemoteControlPlayerSelectActivity.java
@@ -0,0 +1,160 @@
+package uk.org.ngo.squeezer.homescreenwidgets;
+
+import android.appwidget.AppWidgetManager;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.os.Bundle;
+import android.util.Log;
+import android.view.View;
+
+import androidx.annotation.LayoutRes;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.ActionBar;
+
+import java.util.List;
+
+import uk.org.ngo.squeezer.R;
+import uk.org.ngo.squeezer.itemlist.PlayerBaseView;
+import uk.org.ngo.squeezer.itemlist.PlayerListBaseActivity;
+import uk.org.ngo.squeezer.model.Item;
+import uk.org.ngo.squeezer.model.Player;
+import uk.org.ngo.squeezer.model.PlayerState;
+
+/**
+ * The configuration screen for the {@link SqueezerRemoteControl SqueezerRemoteControl} AppWidget.
+ */
+public class SqueezerRemoteControlPlayerSelectActivity extends PlayerListBaseActivity {
+
+ private static final String TAG = SqueezerRemoteControlPlayerSelectActivity.class.getName();
+
+ private static final int GET_BUTTON_ACTIVITY = 1001;
+
+ private int mAppWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID;
+
+
+ public SqueezerRemoteControlPlayerSelectActivity() {
+ super();
+ }
+
+ @Override
+ public void onCreate(Bundle icicle) {
+ super.onCreate(icicle);
+
+ Log.d(TAG, "onCreate");
+
+ // Set the result to CANCELED. This will cause the widget host to cancel
+ // out of the widget placement if the user presses the back button.
+ // Actual result, when successful is below in the onGroupSelected handler
+ setResult(RESULT_CANCELED);
+
+ ActionBar actionBar = getSupportActionBar();
+ if (actionBar != null) {
+ actionBar.setTitle(R.string.configure_select_player);
+ }
+
+ setContentView(R.layout.squeezer_remote_control_configure);
+
+ // Find the widget id from the intent.
+ Intent intent = getIntent();
+ Bundle extras = intent.getExtras();
+ if (extras != null) {
+ mAppWidgetId = extras.getInt(
+ AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID);
+ }
+
+ // If this activity was started with an intent without an app widget ID, finish with an error.
+ if (mAppWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
+ finish();
+ return;
+ }
+
+ }
+
+ public PlayerBaseView createPlayerView() {
+ return new SqueezerRemoteControlConfigureActivityPlayerBaseView();
+ }
+
+ /*
+ This Activity leverages a base Activity which almost all of squeezer uses, itself adding a
+ player status, which we don't want on this activity.
+ */
+ @Override
+ public void setContentView(@LayoutRes int layoutResID) {
+ getDelegate().setContentView(layoutResID);
+
+ setListView(setupListView(findViewById(R.id.item_list)));
+ }
+
+ /*
+ This Activity leverages a base Activity which almost all of squeezer uses, itself adding an
+ actionBar, which we don't want on this activity.
+ */
+ protected boolean addActionBar() {
+ return false;
+ }
+
+ @Override
+ protected boolean needPlayer() {
+ return false;
+ }
+
+ @Override
+ protected void updateAdapter(int count, int start, List items, Class dataType) {
+
+ }
+
+ private class SqueezerRemoteControlConfigureActivityPlayerBaseView extends PlayerBaseView {
+
+ public SqueezerRemoteControlConfigureActivityPlayerBaseView() {
+ super(SqueezerRemoteControlPlayerSelectActivity.this, R.layout.list_item_player_simple);
+ setViewParams(VIEW_PARAM_ICON);
+ }
+
+ @Override
+ public void bindView(View view, Player player) {
+ super.bindView(view, player);
+ ViewHolder viewHolder = (ViewHolder) view.getTag();
+ viewHolder.icon.setImageResource(getModelIcon(player.getModel()));
+
+ PlayerState playerState = player.getPlayerState();
+
+ if (playerState.isPoweredOn()) {
+ viewHolder.text1.setAlpha(1.0f);
+ } else {
+ viewHolder.text1.setAlpha(0.25f);
+ }
+ }
+
+ public void onGroupSelected(View view, Player[] items) {
+ final Context context = SqueezerRemoteControlPlayerSelectActivity.this;
+
+ Intent intent = new Intent(context, SqueezerRemoteControlButtonSelectActivity.class);
+ intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, mAppWidgetId);
+ intent.putExtra(SqueezerRemoteControl.EXTRA_PLAYER, items[0]);
+
+ startActivityForResult(intent, GET_BUTTON_ACTIVITY);
+ }
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+
+ switch (requestCode) {
+ case GET_BUTTON_ACTIVITY:
+ if (resultCode != RESULT_CANCELED) {
+ SqueezerRemoteControl.savePrefs(this.getBaseContext(), data);
+
+ Intent resultValue = new Intent();
+ resultValue.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, mAppWidgetId);
+ setResult(RESULT_OK, resultValue);
+ finish();
+ }
+ break;
+ default:
+ Log.w(TAG, "Unknown request code: " + requestCode);
+ }
+ }
+}
+
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/TextDrawable.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/TextDrawable.java
new file mode 100644
index 000000000..94fc89420
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/TextDrawable.java
@@ -0,0 +1,67 @@
+package uk.org.ngo.squeezer.homescreenwidgets;
+
+import android.content.res.Resources;
+import android.graphics.Canvas;
+import android.graphics.ColorFilter;
+import android.graphics.Paint;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import android.util.TypedValue;
+
+import androidx.annotation.ColorInt;
+
+public class TextDrawable extends Drawable {
+ private static final int DEFAULT_TEXTSIZE = 15;
+ private Paint mPaint;
+ private CharSequence mText;
+ private int mIntrinsicWidth;
+ private int mIntrinsicHeight;
+ float textSize;
+
+
+ public TextDrawable(Resources res, CharSequence text, @ColorInt int color) {
+ mText = text;
+ mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+
+ mPaint.setColor(color);
+ mPaint.setTextAlign(Paint.Align.CENTER);
+ textSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP,
+ DEFAULT_TEXTSIZE, res.getDisplayMetrics());
+ mPaint.setTextSize(textSize);
+ mIntrinsicWidth = (int) (mPaint.measureText(mText, 0, mText.length()) + .5);
+ mIntrinsicHeight = mPaint.getFontMetricsInt(null);
+ }
+
+ @Override
+ public void draw(Canvas canvas) {
+ Rect bounds = getBounds();
+
+ canvas.drawText(mText, 0, mText.length(),
+ bounds.centerX(), bounds.centerY() + textSize/2, mPaint);
+ }
+
+ @Override
+ public int getOpacity() {
+ return mPaint.getAlpha();
+ }
+
+ @Override
+ public int getIntrinsicWidth() {
+ return mIntrinsicWidth;
+ }
+
+ @Override
+ public int getIntrinsicHeight() {
+ return mIntrinsicHeight;
+ }
+
+ @Override
+ public void setAlpha(int alpha) {
+ mPaint.setAlpha(alpha);
+ }
+
+ @Override
+ public void setColorFilter(ColorFilter filter) {
+ mPaint.setColorFilter(filter);
+ }
+}
\ No newline at end of file
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/AlarmView.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/AlarmView.java
new file mode 100644
index 000000000..c5108a6c7
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/AlarmView.java
@@ -0,0 +1,385 @@
+/*
+ * Copyright (c) 2014 Kurt Aaholst
+ *
+ * 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 uk.org.ngo.squeezer.itemlist;
+
+import android.app.Dialog;
+import android.content.res.Resources;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.Typeface;
+import android.graphics.drawable.ColorDrawable;
+import android.graphics.drawable.Drawable;
+import android.os.Bundle;
+import androidx.annotation.NonNull;
+import androidx.fragment.app.FragmentManager;
+import android.text.SpannableString;
+import android.text.format.DateFormat;
+import android.text.style.ForegroundColorSpan;
+import android.text.style.StyleSpan;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.animation.AlphaAnimation;
+import android.view.animation.Animation;
+import android.view.animation.AnimationSet;
+import android.view.animation.ScaleAnimation;
+import android.widget.AdapterView;
+import android.widget.ArrayAdapter;
+import android.widget.CheckedTextView;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.Spinner;
+import android.widget.TextView;
+
+import com.android.datetimepicker.time.RadialPickerLayout;
+import com.android.datetimepicker.time.TimePickerDialog;
+import com.google.common.collect.ImmutableList;
+
+import java.text.DateFormatSymbols;
+import java.util.ArrayList;
+import java.util.List;
+
+import uk.org.ngo.squeezer.R;
+import uk.org.ngo.squeezer.Util;
+import uk.org.ngo.squeezer.framework.BaseItemView;
+import uk.org.ngo.squeezer.framework.BaseListActivity;
+import uk.org.ngo.squeezer.model.Alarm;
+import uk.org.ngo.squeezer.model.AlarmPlaylist;
+import uk.org.ngo.squeezer.util.CompoundButtonWrapper;
+import uk.org.ngo.squeezer.widget.AnimationEndListener;
+import uk.org.ngo.squeezer.widget.UndoBarController;
+
+public class AlarmView extends BaseItemView {
+ private static final int ANIMATION_DURATION = 300;
+
+ private final AlarmsActivity mActivity;
+ private final Resources mResources;
+ private final int mColorSelected;
+ private final float mDensity;
+ private final List mAlarmPlaylists = new ArrayList<>();
+
+ public AlarmView(AlarmsActivity activity) {
+ super(activity);
+ mActivity = activity;
+ mResources = activity.getResources();
+ mColorSelected = mResources.getColor(getActivity().getAttributeValue(R.attr.alarm_dow_selected));
+ mDensity = mResources.getDisplayMetrics().density;
+ }
+
+ @Override
+ public View getAdapterView(View convertView, ViewGroup parent, int position, Alarm item, boolean selected) {
+ View view = getAdapterView(convertView, parent);
+ bindView((AlarmViewHolder) view.getTag(), position, item);
+ return view;
+ }
+
+ private View getAdapterView(View convertView, final ViewGroup parent) {
+ AlarmViewHolder currentViewHolder =
+ (convertView != null && convertView.getTag() instanceof AlarmViewHolder)
+ ? (AlarmViewHolder) convertView.getTag()
+ : null;
+
+ if (currentViewHolder == null) {
+ convertView = getLayoutInflater().inflate(R.layout.list_item_alarm, parent, false);
+ final View alarmView = convertView;
+ final AlarmViewHolder viewHolder = new AlarmViewHolder();
+ viewHolder.is24HourFormat = DateFormat.is24HourFormat(getActivity());
+ viewHolder.timeFormat = viewHolder.is24HourFormat ? "%02d:%02d" : "%d:%02d";
+ String[] amPmStrings = new DateFormatSymbols().getAmPmStrings();
+ viewHolder.am = amPmStrings[0];
+ viewHolder.pm = amPmStrings[1];
+ viewHolder.time = convertView.findViewById(R.id.time);
+ viewHolder.amPm = convertView.findViewById(R.id.am_pm);
+ viewHolder.amPm.setVisibility(viewHolder.is24HourFormat ? View.GONE : View.VISIBLE);
+ viewHolder.enabled = new CompoundButtonWrapper(convertView.findViewById(R.id.enabled));
+ viewHolder.enabled.setOncheckedChangeListener((compoundButton, b) -> {
+ if (getActivity().getService() != null) {
+ viewHolder.alarm.setEnabled(b);
+ getActivity().getService().alarmEnable(viewHolder.alarm.getId(), b);
+ }
+ });
+ viewHolder.repeat = new CompoundButtonWrapper(convertView.findViewById(R.id.repeat));
+ viewHolder.repeat.setOncheckedChangeListener((compoundButton, b) -> {
+ if (getActivity().getService() != null) {
+ viewHolder.alarm.setRepeat(b);
+ getActivity().getService().alarmRepeat(viewHolder.alarm.getId(), b);
+ viewHolder.dowHolder.setVisibility(b ? View.VISIBLE : View.GONE);
+ }
+ });
+ viewHolder.repeat.getButton().setText(R.string.ALARM_ALARM_REPEAT);
+ viewHolder.delete = convertView.findViewById(R.id.delete);
+ viewHolder.playlist = convertView.findViewById(R.id.playlist);
+ viewHolder.dowHolder = convertView.findViewById(R.id.dow);
+ for (int day = 0; day < 7; day++) {
+ ViewGroup dowButton = (ViewGroup) viewHolder.dowHolder.getChildAt(day);
+ final int finalDay = day;
+ dowButton.setOnClickListener(v -> {
+ if (getActivity().getService() != null) {
+ final Alarm alarm = viewHolder.alarm;
+ boolean wasChecked = alarm.isDayActive(finalDay);
+ if (wasChecked) {
+ alarm.clearDay(finalDay);
+ getActivity().getService().alarmRemoveDay(alarm.getId(), finalDay);
+ } else {
+ alarm.setDay(finalDay);
+ getActivity().getService().alarmAddDay(alarm.getId(), finalDay);
+ }
+ setDowText(viewHolder, finalDay);
+ }
+ });
+ viewHolder.dowTexts[day] = (TextView) dowButton.getChildAt(0);
+ }
+ viewHolder.delete.setOnClickListener(view -> {
+ final AnimationSet animationSet = new AnimationSet(true);
+ animationSet.addAnimation(new ScaleAnimation(1F, 1F, 1F, 0.5F));
+ animationSet.addAnimation(new AlphaAnimation(1F, 0F));
+ animationSet.setDuration(ANIMATION_DURATION);
+ animationSet.setAnimationListener(new AnimationEndListener() {
+ @Override
+ public void onAnimationEnd(Animation animation) {
+ mActivity.getItemAdapter().removeItem(viewHolder.position);
+ UndoBarController.show(getActivity(), R.string.ALARM_DELETING, new UndoListener(viewHolder.position, viewHolder.alarm));
+ }
+ });
+
+ alarmView.startAnimation(animationSet);
+ });
+ viewHolder.playlist.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
+ @Override
+ public void onItemSelected(AdapterView> parent, View view, int position, long id) {
+ final AlarmPlaylist selectedAlarmPlaylist = mAlarmPlaylists.get(position);
+ final Alarm alarm = viewHolder.alarm;
+ if (getActivity().getService() != null &&
+ selectedAlarmPlaylist.getId() != null &&
+ !selectedAlarmPlaylist.getId().equals(alarm.getPlayListId())) {
+ alarm.setPlayListId(selectedAlarmPlaylist.getId());
+ getActivity().getService().alarmSetPlaylist(alarm.getId(), selectedAlarmPlaylist);
+ }
+ }
+
+ @Override
+ public void onNothingSelected(AdapterView> parent) {
+ }
+ });
+
+ convertView.setTag(viewHolder);
+ }
+
+ return convertView;
+ }
+
+ private void bindView(final AlarmViewHolder viewHolder, final int position, final Alarm item) {
+ long tod = item.getTod();
+ int hour = (int) (tod / 3600);
+ int minute = (int) ((tod / 60) % 60);
+ int displayHour = hour;
+ if (!viewHolder.is24HourFormat) {
+ displayHour = displayHour % 12;
+ if (displayHour == 0) displayHour = 12;
+ }
+
+ viewHolder.position = position;
+ viewHolder.alarm = item;
+ viewHolder.time.setText(String.format(viewHolder.timeFormat, displayHour, minute));
+ viewHolder.time.setOnClickListener(view -> TimePickerFragment.show(getActivity().getSupportFragmentManager(), item, viewHolder.is24HourFormat, getActivity().getThemeId() == R.style.AppTheme));
+ viewHolder.amPm.setText(hour < 12 ? viewHolder.am : viewHolder.pm);
+ viewHolder.enabled.setChecked(item.isEnabled());
+ viewHolder.repeat.setChecked(item.isRepeat());
+ if (!mAlarmPlaylists.isEmpty()) {
+ viewHolder.playlist.setAdapter(new AlarmPlaylistSpinnerAdapter());
+ for (int i = 0; i < mAlarmPlaylists.size(); i++) {
+ AlarmPlaylist alarmPlaylist = mAlarmPlaylists.get(i);
+ if (alarmPlaylist.getId() != null && alarmPlaylist.getId().equals(item.getPlayListId())) {
+ viewHolder.playlist.setSelection(i);
+ break;
+ }
+ }
+
+ }
+
+ viewHolder.dowHolder.setVisibility(item.isRepeat() ? View.VISIBLE : View.GONE);
+ for (int day = 0; day < 7; day++) {
+ setDowText(viewHolder, day);
+ }
+ }
+
+ private void setDowText(AlarmViewHolder viewHolder, int day) {
+ SpannableString text = new SpannableString(getAlarmShortDayText(day));
+ if (viewHolder.alarm.isDayActive(day)) {
+ text.setSpan(new StyleSpan(Typeface.BOLD), 0, text.length(), 0);
+ text.setSpan(new ForegroundColorSpan(mColorSelected), 0, text.length(), 0);
+ Drawable underline = mResources.getDrawable(R.drawable.underline);
+ float textSize = (new Paint()).measureText(text.toString());
+ underline.setBounds(0, 0, (int) (textSize * mDensity), (int) (1 * mDensity));
+ viewHolder.dowTexts[day].setCompoundDrawables(null, null, null, underline);
+ } else
+ viewHolder.dowTexts[day].setCompoundDrawables(null, null, null, null);
+ viewHolder.dowTexts[day].setText(text);
+ }
+
+ private CharSequence getAlarmShortDayText(int day) {
+ switch (day) {
+ default: return getActivity().getString(R.string.ALARM_SHORT_DAY_0);
+ case 1: return getActivity().getString(R.string.ALARM_SHORT_DAY_1);
+ case 2: return getActivity().getString(R.string.ALARM_SHORT_DAY_2);
+ case 3: return getActivity().getString(R.string.ALARM_SHORT_DAY_3);
+ case 4: return getActivity().getString(R.string.ALARM_SHORT_DAY_4);
+ case 5: return getActivity().getString(R.string.ALARM_SHORT_DAY_5);
+ case 6: return getActivity().getString(R.string.ALARM_SHORT_DAY_6);
+ }
+ }
+
+ @Override
+ public boolean isSelectable(Alarm item) {
+ return false;
+ }
+
+ // Require an immutable list so that caller's can't modify it when this method iterates
+ // over it.
+ public void setAlarmPlaylists(ImmutableList alarmPlaylists) {
+ String currentCategory = null;
+
+ mAlarmPlaylists.clear();
+ for (AlarmPlaylist alarmPlaylist : alarmPlaylists) {
+ if (!alarmPlaylist.getCategory().equals(currentCategory)) {
+ AlarmPlaylist categoryAlarmPlaylist = new AlarmPlaylist();
+ categoryAlarmPlaylist.setCategory(alarmPlaylist.getCategory());
+ mAlarmPlaylists.add(categoryAlarmPlaylist);
+ }
+ mAlarmPlaylists.add(alarmPlaylist);
+ currentCategory = alarmPlaylist.getCategory();
+ }
+ }
+
+ private static class AlarmViewHolder {
+ int position;
+ public boolean is24HourFormat;
+ String timeFormat;
+ String am;
+ String pm;
+ Alarm alarm;
+ TextView time;
+ TextView amPm;
+ CompoundButtonWrapper enabled;
+ CompoundButtonWrapper repeat;
+ ImageView delete;
+ Spinner playlist;
+ LinearLayout dowHolder;
+ final TextView[] dowTexts = new TextView[7];
+ }
+
+ public static class TimePickerFragment extends TimePickerDialog implements TimePickerDialog.OnTimeSetListener {
+ BaseListActivity activity;
+ Alarm alarm;
+
+ @Override
+ @NonNull
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ activity = (BaseListActivity) getActivity();
+ alarm = getArguments().getParcelable("alarm");
+ setOnTimeSetListener(this);
+ return super.onCreateDialog(savedInstanceState);
+ }
+
+ public static void show(FragmentManager manager, Alarm alarm, boolean is24HourFormat, boolean dark) {
+ long tod = alarm.getTod();
+ int hour = (int) (tod / 3600);
+ int minute = (int) ((tod / 60) % 60);
+
+ TimePickerFragment fragment = new TimePickerFragment();
+ Bundle bundle = new Bundle();
+ bundle.putParcelable("alarm", alarm);
+ fragment.setArguments(bundle);
+ fragment.initialize(fragment, hour, minute, is24HourFormat);
+ fragment.setThemeDark(dark);
+ fragment.show(manager, TimePickerFragment.class.getSimpleName());
+ }
+
+ @Override
+ public void onTimeSet(RadialPickerLayout view, int hourOfDay, int minute) {
+ if (activity.getService() != null) {
+ int time = (hourOfDay * 60 + minute) * 60;
+ alarm.setTod(time);
+ activity.getService().alarmSetTime(alarm.getId(), time);
+ activity.getItemAdapter().notifyDataSetChanged();
+ }
+ }
+ }
+
+ private class AlarmPlaylistSpinnerAdapter extends ArrayAdapter {
+
+ public AlarmPlaylistSpinnerAdapter() {
+ super(getActivity(), android.R.layout.simple_spinner_dropdown_item, mAlarmPlaylists);
+ }
+
+ @Override
+ public boolean areAllItemsEnabled() {
+ return false;
+ }
+
+ @Override
+ public boolean isEnabled(int position) {
+ return (mAlarmPlaylists.get(position).getId() != null);
+ }
+
+ @NonNull
+ @Override
+ public View getView(int position, View convertView, @NonNull ViewGroup parent) {
+ return Util.getSpinnerItemView(getActivity(), convertView, parent, getItem(position).getName());
+ }
+
+ @Override
+ public View getDropDownView(int position, View convertView, @NonNull ViewGroup parent) {
+ if (!isEnabled(position)) {
+ FrameLayout view = (FrameLayout) getActivity().getLayoutInflater().inflate(R.layout.alarm_playlist_category_dropdown_item, parent, false);
+ CheckedTextView spinnerItemView = view.findViewById(R.id.text);
+ spinnerItemView.setText(getItem(position).getCategory());
+ spinnerItemView.setTypeface(spinnerItemView.getTypeface(), Typeface.BOLD);
+ // Hide the checkmark for headings.
+ spinnerItemView.setCheckMarkDrawable(new ColorDrawable(Color.TRANSPARENT));
+ return view;
+ } else {
+ FrameLayout view = (FrameLayout) getActivity().getLayoutInflater().inflate(R.layout.alarm_playlist_dropdown_item, parent, false);
+ TextView spinnerItemView = view.findViewById(R.id.text);
+ spinnerItemView.setText(getItem(position).getName());
+ return view;
+ }
+ }
+ }
+
+ private class UndoListener implements UndoBarController.UndoListener {
+ private final int position;
+ private final Alarm alarm;
+
+ public UndoListener(int position, Alarm alarm) {
+ this.position = position;
+ this.alarm = alarm;
+ }
+
+ @Override
+ public void onUndo() {
+ mActivity.getItemAdapter().insertItem(position, alarm);
+ }
+
+ @Override
+ public void onDone() {
+ if (mActivity.getService() != null) {
+ mActivity.getService().alarmDelete(alarm.getId());
+ }
+ }
+ }
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/AlarmsActivity.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/AlarmsActivity.java
new file mode 100644
index 000000000..5de390118
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/AlarmsActivity.java
@@ -0,0 +1,310 @@
+/*
+ * Copyright (c) 2014 Kurt Aaholst
+ *
+ * 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 uk.org.ngo.squeezer.itemlist;
+
+import android.app.Activity;
+import android.app.Dialog;
+import android.content.Intent;
+import android.os.Bundle;
+import androidx.annotation.MainThread;
+import androidx.annotation.NonNull;
+import androidx.fragment.app.FragmentManager;
+import android.text.format.DateFormat;
+import android.view.View;
+import android.widget.CompoundButton;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import com.android.datetimepicker.time.RadialPickerLayout;
+import com.android.datetimepicker.time.TimePickerDialog;
+import com.google.common.collect.ImmutableList;
+
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import uk.org.ngo.squeezer.R;
+import uk.org.ngo.squeezer.itemlist.dialog.AlarmSettingsDialog;
+import uk.org.ngo.squeezer.framework.BaseListActivity;
+import uk.org.ngo.squeezer.framework.ItemView;
+import uk.org.ngo.squeezer.model.Alarm;
+import uk.org.ngo.squeezer.model.AlarmPlaylist;
+import uk.org.ngo.squeezer.model.Player;
+import uk.org.ngo.squeezer.service.ISqueezeService;
+import uk.org.ngo.squeezer.service.event.ActivePlayerChanged;
+import uk.org.ngo.squeezer.service.event.PlayerPrefReceived;
+import uk.org.ngo.squeezer.util.CompoundButtonWrapper;
+import uk.org.ngo.squeezer.widget.UndoBarController;
+
+public class AlarmsActivity extends BaseListActivity implements AlarmSettingsDialog.HostActivity {
+ /** The most recent active player. */
+ private Player mActivePlayer;
+
+ private AlarmView mAlarmView;
+
+ /** Toggle/Switch that controls whether all alarms are enabled or disabled. */
+ private CompoundButtonWrapper mAlarmsEnabledButton;
+
+ /** View that contains all_alarms_{on,off}_hint text. */
+ private TextView mAllAlarmsHintView;
+
+ /** Settings button. */
+ private ImageView mSettingsButton;
+
+ /** Have player preference values been requested from the server? */
+ private boolean mPrefsOrdered = false;
+
+ /** Maps from a @Player.Pref.Name to its value. */
+ private final Map mPlayerPrefs = new HashMap<>();
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ ((TextView)findViewById(R.id.all_alarms_text)).setText(R.string.ALARM_ALL_ALARMS);
+ mAllAlarmsHintView = findViewById(R.id.all_alarms_hint);
+
+ mAlarmsEnabledButton = new CompoundButtonWrapper((CompoundButton) findViewById(R.id.alarms_enabled));
+ findViewById(R.id.add_alarm).setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ TimePickerFragment.show(getSupportFragmentManager(), DateFormat.is24HourFormat(AlarmsActivity.this), getThemeId() == R.style.AppTheme);
+ }
+ });
+
+ mSettingsButton = findViewById(R.id.settings);
+ mSettingsButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ new AlarmSettingsDialog().show(getSupportFragmentManager(), "AlarmSettingsDialog");
+ }
+ });
+
+ if (savedInstanceState != null) {
+ mActivePlayer = savedInstanceState.getParcelable("activePlayer");
+ }
+ }
+
+ @Override
+ protected void onPostCreate(Bundle savedInstanceState) {
+ super.onPostCreate(savedInstanceState);
+ mAlarmsEnabledButton.setOncheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
+ @Override
+ public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
+ mAllAlarmsHintView.setText(isChecked ? R.string.all_alarms_on_hint : R.string.all_alarms_off_hint);
+ if (getService() != null) {
+ getService().playerPref(Player.Pref.ALARMS_ENABLED, isChecked ? "1" : "0");
+ }
+ }
+ });
+ }
+
+ @Override
+ protected void onServiceConnected(@NonNull ISqueezeService service) {
+ super.onServiceConnected(service);
+ maybeOrderPrefs(service);
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ ISqueezeService service = getService();
+ if (service != null) {
+ maybeOrderPrefs(service);
+ }
+ }
+
+ private void maybeOrderPrefs(ISqueezeService service) {
+ if (!mPrefsOrdered) {
+ mPrefsOrdered = true;
+
+ service.playerPref(Player.Pref.ALARM_FADE_SECONDS);
+ service.playerPref(Player.Pref.ALARM_DEFAULT_VOLUME);
+ service.playerPref(Player.Pref.ALARM_SNOOZE_SECONDS);
+ service.playerPref(Player.Pref.ALARM_TIMEOUT_SECONDS);
+ }
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ UndoBarController.hide(this);
+ }
+
+ @Override
+ protected void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ outState.putParcelable("activePlayer", mActivePlayer);
+ }
+
+ public static void show(Activity context) {
+ final Intent intent = new Intent(context, AlarmsActivity.class)
+ .addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT);
+ context.startActivity(intent);
+ }
+
+ @Override
+ protected int getContentView() {
+ return R.layout.item_list_player_alarms;
+ }
+
+ @Override
+ public ItemView createItemView() {
+ mAlarmView = new AlarmView(this);
+ return mAlarmView;
+ }
+
+ @Override
+ protected boolean needPlayer() {
+ return true;
+ }
+
+ @Override
+ protected void orderPage(@NonNull ISqueezeService service, int start) {
+ service.alarms(start, this);
+ if (start == 0) {
+ mActivePlayer = service.getActivePlayer();
+ service.alarmPlaylists(mAlarmPlaylistsCallback);
+
+ mAlarmsEnabledButton.setEnabled(false);
+ service.playerPref(Player.Pref.ALARMS_ENABLED);
+ }
+ }
+
+ private final IServiceItemListCallback mAlarmPlaylistsCallback = new IServiceItemListCallback() {
+ private final List mAlarmPlaylists = new ArrayList<>();
+
+ @Override
+ public void onItemsReceived(final int count, final int start, Map parameters, final List items, Class dataType) {
+ runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ if (start == 0) {
+ mAlarmPlaylists.clear();
+ }
+
+ mAlarmPlaylists.addAll(items);
+ if (start + items.size() >= count) {
+ mAlarmView.setAlarmPlaylists(ImmutableList.copyOf(mAlarmPlaylists));
+ getItemAdapter().notifyDataSetChanged();
+
+ }
+ }
+ });
+ }
+
+ @Override
+ public Object getClient() {
+ return AlarmsActivity.this;
+ }
+ };
+
+ public void onEventMainThread(PlayerPrefReceived event) {
+ if (!event.player.equals(getService().getActivePlayer())) {
+ return;
+ }
+
+ mPlayerPrefs.put(event.pref, event.value);
+
+ if (Player.Pref.ALARMS_ENABLED.equals(event.pref)) {
+ boolean checked = Integer.valueOf(event.value) > 0;
+ mAlarmsEnabledButton.setEnabled(true);
+ mAlarmsEnabledButton.setChecked(checked);
+ mAllAlarmsHintView.setText(checked ? R.string.all_alarms_on_hint : R.string.all_alarms_off_hint);
+ }
+
+ // The settings dialog can only be shown after all 4 prefs have been received, so
+ // that it can show their values.
+ if (mSettingsButton.getVisibility() == View.INVISIBLE) {
+ if (mPlayerPrefs.containsKey(Player.Pref.ALARM_DEFAULT_VOLUME) &&
+ mPlayerPrefs.containsKey(Player.Pref.ALARM_SNOOZE_SECONDS) &&
+ mPlayerPrefs.containsKey(Player.Pref.ALARM_TIMEOUT_SECONDS) &&
+ mPlayerPrefs.containsKey(Player.Pref.ALARM_FADE_SECONDS)) {
+ mSettingsButton.setVisibility(View.VISIBLE);
+ }
+ }
+ }
+
+ @MainThread
+ public void onEventMainThread(ActivePlayerChanged event) {
+ super.onEventMainThread(event);
+ mActivePlayer = event.player;
+ }
+
+ @Override
+ @NonNull
+ public Player getPlayer() {
+ return mActivePlayer;
+ }
+
+ @Override
+ @NonNull
+ public String getPlayerPref(@NonNull @Player.Pref.Name String playerPref, @NonNull String def) {
+ String ret = mPlayerPrefs.get(playerPref);
+ if (ret == null) {
+ ret = def;
+ }
+ return ret;
+ }
+
+ @Override
+ public void onPositiveClick(int volume, int snooze, int timeout, boolean fade) {
+ ISqueezeService service = getService();
+ if (service != null) {
+ service.playerPref(Player.Pref.ALARM_DEFAULT_VOLUME, String.valueOf(volume));
+ service.playerPref(Player.Pref.ALARM_SNOOZE_SECONDS, String.valueOf(snooze));
+ service.playerPref(Player.Pref.ALARM_TIMEOUT_SECONDS, String.valueOf(timeout));
+ service.playerPref(Player.Pref.ALARM_FADE_SECONDS, fade ? "1" : "0");
+ }
+ }
+
+ public static class TimePickerFragment extends TimePickerDialog implements TimePickerDialog.OnTimeSetListener {
+ BaseListActivity activity;
+
+ @Override
+ @NonNull
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ activity = (BaseListActivity) getActivity();
+ setOnTimeSetListener(this);
+ return super.onCreateDialog(savedInstanceState);
+ }
+
+ public static void show(FragmentManager manager, boolean is24HourMode, boolean dark) {
+ // Use the current time as the default values for the picker
+ final Calendar c = Calendar.getInstance();
+ int hour = c.get(Calendar.HOUR_OF_DAY);
+ int minute = c.get(Calendar.MINUTE);
+
+ TimePickerFragment fragment = new TimePickerFragment();
+ fragment.initialize(fragment, hour, minute, is24HourMode);
+ fragment.setThemeDark(dark);
+ fragment.show(manager, TimePickerFragment.class.getSimpleName());
+ }
+
+ @Override
+ public void onTimeSet(RadialPickerLayout view, int hourOfDay, int minute) {
+ if (activity.getService() != null) {
+ activity.getService().alarmAdd((hourOfDay * 60 + minute) * 60);
+ // TODO add to list and animate the new alarm in
+ activity.clearAndReOrderItems();
+ }
+ }
+ }
+
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/CurrentPlaylistActivity.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/CurrentPlaylistActivity.java
new file mode 100644
index 000000000..d678644d5
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/CurrentPlaylistActivity.java
@@ -0,0 +1,252 @@
+/*
+ * Copyright (c) 2011 Kurt Aaholst
+ *
+ * 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 uk.org.ngo.squeezer.itemlist;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.widget.AbsListView;
+import android.widget.ListView;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.ActionBar;
+import androidx.core.view.GestureDetectorCompat;
+
+import java.util.Map;
+
+import uk.org.ngo.squeezer.R;
+import uk.org.ngo.squeezer.framework.ItemView;
+import uk.org.ngo.squeezer.itemlist.dialog.PlaylistSaveDialog;
+import uk.org.ngo.squeezer.model.JiveItem;
+import uk.org.ngo.squeezer.service.ISqueezeService;
+import uk.org.ngo.squeezer.service.event.MusicChanged;
+import uk.org.ngo.squeezer.service.event.PlaylistChanged;
+import uk.org.ngo.squeezer.widget.OnSwipeListener;
+import uk.org.ngo.squeezer.widget.UndoBarController;
+
+/**
+ * Activity that shows the songs in the current playlist.
+ */
+public class CurrentPlaylistActivity extends JiveItemListActivity {
+ private int skipPlaylistChanged = 0;
+ private int draggedIndex = -1;
+
+ /**
+ * Called when the activity is first created.
+ */
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ ActionBar actionBar = getSupportActionBar();
+ if (actionBar != null) {
+ actionBar.setHomeAsUpIndicator(R.drawable.ic_action_close);
+ }
+
+ final GestureDetectorCompat detector = new GestureDetectorCompat(this, new OnSwipeListener() {
+ @Override
+ public boolean onSwipeDown() {
+ finish();
+ return true;
+ }
+ });
+ findViewById(R.id.parent_container).setOnTouchListener((v, event) -> {
+ detector.onTouchEvent(event);
+ return true;
+ });
+
+ ignoreIconMessages = true;
+ }
+
+ @Override
+ public void onPause() {
+ if (isFinishing()) {
+ overridePendingTransition(android.R.anim.fade_in, R.anim.slide_out_down);
+ }
+ super.onPause();
+ }
+
+ @Override
+ protected void orderPage(@NonNull ISqueezeService service, int start) {
+ service.pluginItems(start, "status", this);
+ }
+
+ @Override
+ protected boolean needPlayer() {
+ return true;
+ }
+
+ @Override
+ public void setListView(AbsListView listView) {
+ super.setListView(listView);
+ listView.setOnDragListener(new ListDragListener(this));
+ }
+
+ @Override
+ public ItemView createItemView() {
+ return new CurrentPlaylistItemView(this);
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ getMenuInflater().inflate(R.menu.currentplaylistmenu, menu);
+ return super.onCreateOptionsMenu(menu);
+ }
+
+ /**
+ * Sets the enabled state of the R.menu.currentplaylistmenu items.
+ */
+ @Override
+ public boolean onPrepareOptionsMenu(Menu menu) {
+ final int[] ids = {R.id.menu_item_playlist_clear, R.id.menu_item_playlist_save,
+ R.id.menu_item_playlist_show_current_song};
+
+ final boolean knowCurrentPlaylist = getCurrentPlaylist() != null;
+
+ for (int id : ids) {
+ MenuItem item = menu.findItem(id);
+ item.setVisible(knowCurrentPlaylist);
+ }
+
+ return super.onPrepareOptionsMenu(menu);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ switch (item.getItemId()) {
+ case R.id.menu_item_playlist_clear:
+ UndoBarController.show(this, R.string.CLEAR_PLAYLIST, new UndoBarController.UndoListener() {
+ @Override
+ public void onUndo() {
+ }
+
+ @Override
+ public void onDone() {
+ if (getService() != null) {
+ getService().playlistClear();
+ }
+ }
+ });
+ return true;
+ case R.id.menu_item_playlist_save:
+ PlaylistSaveDialog.addTo(this, getCurrentPlaylist());
+ return true;
+ case R.id.menu_item_playlist_show_current_song:
+ getListView().smoothScrollToPositionFromTop(getItemAdapter().getSelectedIndex(), 0);
+ return true;
+
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ private String getCurrentPlaylist() {
+ if (getService() == null) {
+ return null;
+ }
+ return getService().getCurrentPlaylist();
+ }
+
+ public void onEventMainThread(MusicChanged event) {
+ if (getService() == null) {
+ return;
+ }
+ if (event.player.equals(getService().getActivePlayer())) {
+ getItemAdapter().setSelectedIndex(event.playerState.getCurrentPlaylistIndex());
+ getItemAdapter().notifyDataSetChanged();
+ }
+ }
+
+ public void onEventMainThread(PlaylistChanged event) {
+ if (getService() == null) {
+ return;
+ }
+ if (skipPlaylistChanged > 0) {
+ skipPlaylistChanged--;
+ return;
+ }
+ if (event.player.equals(getService().getActivePlayer())) {
+ clearAndReOrderItems();
+ getItemAdapter().notifyDataSetChanged();
+ }
+ }
+
+ public void skipPlaylistChanged() {
+ skipPlaylistChanged++;
+ }
+
+ public int getDraggedIndex() {
+ return draggedIndex;
+ }
+
+ public void setDraggedIndex(int draggedIndex) {
+ this.draggedIndex = draggedIndex;
+ getItemAdapter().notifyDataSetChanged();
+ }
+
+ @Override
+ public void onItemsReceived(int count, int start, Map parameters, List items, Class dataType) {
+ List playlistItems = new ArrayList<>();
+ for (JiveItem item : items) {
+ // Skip special items (global actions) as there are handled locally
+ if ((item.hasSubItems() || item.hasInput())) {
+ count--;
+ } else {
+ playlistItems.add(item);
+ if (item.moreAction == null) {
+ item.moreAction = item.goAction;
+ item.goAction = null;
+ }
+ }
+ }
+ super.onItemsReceived(count, start, parameters, playlistItems, dataType);
+
+ ISqueezeService service = getService();
+ if (service == null) {
+ return;
+ }
+
+ int selectedIndex = service.getPlayerState().getCurrentPlaylistIndex();
+ getItemAdapter().setSelectedIndex(selectedIndex);
+ // Initially position the list at the currently playing song.
+ // Do it again once it has loaded because the newly displayed items
+ // may push the current song outside the displayed area
+ if (start == 0 || (start <= selectedIndex && selectedIndex < start + playlistItems.size())) {
+ runOnUiThread(() -> ((ListView) getListView()).setSelectionFromTop(selectedIndex, 0));
+ }
+ }
+
+ public static void show(Context context) {
+ final Intent intent = new Intent(context, CurrentPlaylistActivity.class)
+ .addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT);
+ intent.putExtra(JiveItem.class.getName(), JiveItem.CURRENT_PLAYLIST);
+
+ if (!(context instanceof Activity)) {
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ }
+ context.startActivity(intent);
+ if (context instanceof Activity) {
+ ((Activity) context).overridePendingTransition(R.anim.slide_in_up, android.R.anim.fade_out);
+ }
+ }
+
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/CurrentPlaylistItemView.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/CurrentPlaylistItemView.java
new file mode 100644
index 000000000..7e379e01c
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/CurrentPlaylistItemView.java
@@ -0,0 +1,184 @@
+/*
+ * Copyright (c) 2020 Kurt Aaholst
+ *
+ * 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 uk.org.ngo.squeezer.itemlist;
+
+import android.content.ClipData;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.LayerDrawable;
+import android.graphics.drawable.TransitionDrawable;
+import android.os.Build;
+import android.view.Gravity;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.animation.AlphaAnimation;
+import android.view.animation.Animation;
+import android.view.animation.AnimationSet;
+import android.view.animation.ScaleAnimation;
+
+import androidx.appcompat.content.res.AppCompatResources;
+import androidx.core.view.GestureDetectorCompat;
+import androidx.palette.graphics.Palette;
+
+import uk.org.ngo.squeezer.R;
+import uk.org.ngo.squeezer.Util;
+import uk.org.ngo.squeezer.framework.BaseItemView;
+import uk.org.ngo.squeezer.model.JiveItem;
+import uk.org.ngo.squeezer.service.ISqueezeService;
+import uk.org.ngo.squeezer.widget.AnimationEndListener;
+import uk.org.ngo.squeezer.widget.OnSwipeListener;
+import uk.org.ngo.squeezer.widget.UndoBarController;
+
+class CurrentPlaylistItemView extends JiveItemView {
+ private static final int ANIMATION_DURATION = 200;
+
+ private final CurrentPlaylistActivity activity;
+
+ public CurrentPlaylistItemView(CurrentPlaylistActivity activity) {
+ super(activity, activity.window.windowStyle);
+ this.activity = activity;
+ }
+
+ @Override
+ public BaseItemView.ViewHolder createViewHolder(View itemView) {
+ return new ViewHolder(itemView);
+ }
+
+ @Override
+ public void bindView(View view, JiveItem item) {
+ super.bindView(view, item);
+ view.setBackgroundResource(getActivity().getAttributeValue(R.attr.selectableItemBackground));
+ ViewHolder viewHolder = (ViewHolder) view.getTag();
+
+ if (viewHolder.position == activity.getItemAdapter().getSelectedIndex()) {
+ viewHolder.text1.setTextAppearance(getActivity(), R.style.SqueezerTextAppearance_ListItem_Primary_Highlight);
+ viewHolder.text2.setTextAppearance(getActivity(), R.style.SqueezerTextAppearance_ListItem_Secondary_Highlight);
+ } else {
+ viewHolder.text1.setTextAppearance(getActivity(), R.style.SqueezerTextAppearance_ListItem_Primary);
+ viewHolder.text2.setTextAppearance(getActivity(), R.style.SqueezerTextAppearance_ListItem_Secondary);
+ }
+
+ view.setAlpha(viewHolder.position == activity.getDraggedIndex() ? 0 : 1);
+
+ final GestureDetectorCompat detector = new GestureDetectorCompat(getActivity(), new OnSwipeListener() {
+ @Override
+ public boolean onDown(MotionEvent e) {
+ view.setPressed(true);
+ return super.onDown(e);
+ }
+
+ @Override
+ public void onLongPress(MotionEvent e) {
+ activity.setDraggedIndex(viewHolder.position);
+ view.setPressed(false);
+ ClipData data = ClipData.newPlainText("", "");
+ View.DragShadowBuilder shadowBuilder = new View.DragShadowBuilder(view);
+ view.setActivated(true);
+ view.startDrag(data, shadowBuilder, null, 0);
+ }
+
+ @Override
+ public boolean onSingleTapUp(MotionEvent e) {
+ onItemSelected(view, viewHolder.position, item);
+ return true;
+ }
+
+ @Override
+ public boolean onSwipeLeft() {
+ removeItem(view, viewHolder.position, item);
+ return true;
+ }
+
+ @Override
+ public boolean onSwipeRight() {
+ removeItem(view, viewHolder.position, item);
+ return true;
+ }
+ });
+
+ view.setOnTouchListener((v, event) -> {
+ if (event.getAction() == MotionEvent.ACTION_UP) {
+ view.setPressed(false);
+ view.performClick();
+ }
+ return detector.onTouchEvent(event);
+ });
+ }
+
+ @Override
+ public void onIcon(ViewHolder viewHolder) {
+ if (viewHolder.position == activity.getItemAdapter().getSelectedIndex() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ Drawable icon = viewHolder.icon.getDrawable();
+ Drawable marker = AppCompatResources.getDrawable(activity, R.drawable.ic_action_nowplaying);
+ Palette colorPalette = Palette.from(Util.drawableToBitmap(icon)).generate();
+ marker.setTint(colorPalette.getDominantSwatch().getBodyTextColor());
+
+ LayerDrawable layerDrawable = new LayerDrawable(new Drawable[]{icon, marker});
+ layerDrawable.setLayerGravity(1, Gravity.CENTER);
+
+ viewHolder.icon.setImageDrawable(layerDrawable);
+ }
+ }
+
+ private void removeItem(View view, int position, JiveItem item) {
+ final AnimationSet animationSet = new AnimationSet(true);
+ animationSet.addAnimation(new ScaleAnimation(1F, 1F, 1F, 0.5F));
+ animationSet.addAnimation(new AlphaAnimation(1F, 0F));
+ animationSet.setDuration(ANIMATION_DURATION);
+ animationSet.setAnimationListener(new AnimationEndListener() {
+ @Override
+ public void onAnimationEnd(Animation animation) {
+ activity.getItemAdapter().removeItem(position);
+ UndoBarController.show(activity, activity.getString(R.string.JIVE_POPUP_REMOVING_FROM_PLAYLIST, item.getName()), new UndoBarController.UndoListener() {
+ @Override
+ public void onUndo() {
+ activity.getItemAdapter().insertItem(position, item);
+ }
+
+ @Override
+ public void onDone() {
+ ISqueezeService service = activity.getService();
+ if (service != null) {
+ service.playlistRemove(position);
+ activity.skipPlaylistChanged();
+ }
+ }
+ });
+ }
+ });
+
+ view.startAnimation(animationSet);
+
+
+ }
+
+ @Override
+ public boolean isSelectable(JiveItem item) {
+ return true;
+ }
+
+ /**
+ * Jumps to whichever song the user chose.
+ */
+ @Override
+ public boolean onItemSelected(View view, int index, JiveItem item) {
+ ISqueezeService service = getActivity().getService();
+ if (service != null) {
+ getActivity().getService().playlistIndex(index);
+ }
+ return false;
+ }
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/HomeActivity.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/HomeActivity.java
new file mode 100644
index 000000000..6dfc5d60d
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/HomeActivity.java
@@ -0,0 +1,103 @@
+/*
+ * Copyright (c) 2009 Google Inc. 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 uk.org.ngo.squeezer.itemlist;
+
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.os.Bundle;
+import androidx.preference.PreferenceManager;
+import androidx.annotation.MainThread;
+import androidx.appcompat.app.ActionBar;
+
+import uk.org.ngo.squeezer.Preferences;
+import uk.org.ngo.squeezer.R;
+import uk.org.ngo.squeezer.dialog.ChangeLogDialog;
+import uk.org.ngo.squeezer.dialog.TipsDialog;
+import uk.org.ngo.squeezer.model.JiveItem;
+import uk.org.ngo.squeezer.service.event.HandshakeComplete;
+
+public class HomeActivity extends HomeMenuActivity {
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ getIntent().putExtra(JiveItem.class.getName(), JiveItem.HOME);
+ super.onCreate(savedInstanceState);
+
+ // Turn off the home icon.
+ ActionBar actionBar = getSupportActionBar();
+ if (actionBar != null) {
+ actionBar.setDisplayHomeAsUpEnabled(false);
+ }
+
+ PreferenceManager.setDefaultValues(this, R.xml.preferences, false);
+
+ // Show the change log if necessary.
+ ChangeLogDialog changeLog = new ChangeLogDialog(this);
+ if (changeLog.isFirstRun()) {
+ if (changeLog.isFirstRunEver()) {
+ changeLog.skipLogDialog();
+ } else {
+ changeLog.getThemedLogDialog().show();
+ }
+ }
+ }
+
+ @MainThread
+ public void onEventMainThread(HandshakeComplete event) {
+ super.onEventMainThread(event);
+
+ // Show a tip about volume controls, if this is the first time this app
+ // has run. TODO: Add more robust and general 'tips' functionality.
+ PackageInfo pInfo;
+ try {
+ final SharedPreferences preferences = getSharedPreferences(Preferences.NAME,
+ 0);
+
+ pInfo = getPackageManager().getPackageInfo(getPackageName(),
+ PackageManager.GET_META_DATA);
+ if (preferences.getLong("lastRunVersionCode", 0) == 0) {
+ new TipsDialog().show(getSupportFragmentManager(), "TipsDialog");
+ SharedPreferences.Editor editor = preferences.edit();
+ editor.putLong("lastRunVersionCode", pInfo.versionCode);
+ editor.apply();
+ }
+ } catch (PackageManager.NameNotFoundException e) {
+ // Nothing to do, don't crash.
+ }
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ }
+
+ public static void show(Context context) {
+ Intent intent = new Intent(context, HomeActivity.class)
+ .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
+ if (!(context instanceof Activity))
+ intent = intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+
+ context.startActivity(intent);
+ }
+
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/HomeMenuActivity.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/HomeMenuActivity.java
new file mode 100644
index 000000000..664563688
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/HomeMenuActivity.java
@@ -0,0 +1,95 @@
+/*
+ * Copyright (c) 2009 Google Inc. 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 uk.org.ngo.squeezer.itemlist;
+
+
+import android.app.Activity;
+import android.content.Intent;
+import androidx.annotation.NonNull;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+import uk.org.ngo.squeezer.Preferences;
+import uk.org.ngo.squeezer.model.JiveItem;
+import uk.org.ngo.squeezer.model.Window;
+import uk.org.ngo.squeezer.itemlist.dialog.ArtworkListLayout;
+import uk.org.ngo.squeezer.service.ISqueezeService;
+import uk.org.ngo.squeezer.service.event.HomeMenuEvent;
+
+public class HomeMenuActivity extends JiveItemListActivity {
+
+ @Override
+ protected void orderPage(@NonNull ISqueezeService service, int start) {
+ // Do nothing we get the home menu from the sticky HomeMenuEvent
+ }
+
+ @Override
+ public ArtworkListLayout getPreferredListLayout() {
+ return new Preferences(this).getHomeMenuLayout();
+ }
+
+ @Override
+ protected void saveListLayout(ArtworkListLayout listLayout) {
+ new Preferences(this).setHomeMenuLayout(listLayout);
+ }
+
+ public void onEvent(HomeMenuEvent event) {
+ runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ if (parent.window == null) {
+ applyWindowStyle(Window.WindowStyle.HOME_MENU);
+ }
+ if (parent != JiveItem.HOME && window.text == null) {
+ updateHeader(parent);
+ }
+ clearItemAdapter();
+ }
+ });
+ List menu = getMenuNode(parent.getId(), event.menuItems);
+ onItemsReceived(menu.size(), 0, menu, JiveItem.class);
+ }
+
+ private List getMenuNode(String node, List homeMenu) {
+ ArrayList menu = new ArrayList<>();
+ for (JiveItem item : homeMenu) {
+ if (node.equals(item.getNode())) {
+ menu.add(item);
+ }
+ }
+ Collections.sort(menu, new Comparator() {
+ @Override
+ public int compare(JiveItem o1, JiveItem o2) {
+ if (o1.getWeight() == o2.getWeight()) {
+ return o1.getName().compareTo(o2.getName());
+ }
+ return o1.getWeight() - o2.getWeight();
+ }
+ });
+ return menu;
+ }
+
+ public static void show(Activity activity, JiveItem item) {
+ final Intent intent = new Intent(activity, HomeMenuActivity.class);
+ intent.putExtra(JiveItem.class.getName(), item);
+ activity.startActivity(intent);
+ }
+
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/IServiceItemListCallback.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/IServiceItemListCallback.java
new file mode 100644
index 000000000..e2f9bc99c
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/IServiceItemListCallback.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2011 Kurt Aaholst
+ *
+ * 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 uk.org.ngo.squeezer.itemlist;
+import java.util.List;
+import java.util.Map;
+
+import uk.org.ngo.squeezer.service.ServiceCallback;
+
+public interface IServiceItemListCallback extends ServiceCallback {
+ void onItemsReceived(int count, int start, Map parameters, List items, Class dataType);
+}
+
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/JiveItemListActivity.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/JiveItemListActivity.java
new file mode 100644
index 000000000..0117e97c8
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/JiveItemListActivity.java
@@ -0,0 +1,555 @@
+/*
+ * Copyright (c) 2011 Kurt Aaholst
+ *
+ * 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 uk.org.ngo.squeezer.itemlist;
+
+import android.app.Activity;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.res.Resources;
+import android.os.Bundle;
+import androidx.annotation.LayoutRes;
+import androidx.annotation.NonNull;
+import android.text.TextUtils;
+import android.view.KeyEvent;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.inputmethod.EditorInfo;
+import android.widget.AbsListView;
+import android.widget.EditText;
+import android.widget.GridView;
+import android.widget.TextView;
+
+import com.google.android.material.button.MaterialButton;
+import com.google.android.material.textfield.TextInputLayout;
+
+import java.util.List;
+import java.util.Map;
+
+import uk.org.ngo.squeezer.NowPlayingActivity;
+import uk.org.ngo.squeezer.Preferences;
+import uk.org.ngo.squeezer.R;
+import uk.org.ngo.squeezer.Util;
+import uk.org.ngo.squeezer.dialog.NetworkErrorDialogFragment;
+import uk.org.ngo.squeezer.model.Action;
+import uk.org.ngo.squeezer.framework.BaseItemView;
+import uk.org.ngo.squeezer.framework.BaseListActivity;
+import uk.org.ngo.squeezer.framework.ItemView;
+import uk.org.ngo.squeezer.model.JiveItem;
+import uk.org.ngo.squeezer.model.Window;
+import uk.org.ngo.squeezer.itemlist.dialog.ArtworkListLayout;
+import uk.org.ngo.squeezer.service.ISqueezeService;
+import uk.org.ngo.squeezer.service.event.HandshakeComplete;
+import uk.org.ngo.squeezer.util.ImageFetcher;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+/*
+ * The activity's content view scrolls in from the right, and disappear to the left, to provide a
+ * spatial component to navigation.
+ */
+public class JiveItemListActivity extends BaseListActivity
+ implements NetworkErrorDialogFragment.NetworkErrorDialogListener {
+ private static final int GO = 1;
+ private static final String FINISH = "FINISH";
+ private static final String RELOAD = "RELOAD";
+
+ private JiveItemViewLogic pluginViewDelegate;
+ private boolean register;
+ protected JiveItem parent;
+ private Action action;
+ Window window = new Window();
+
+ private MenuItem menuItemList;
+ private MenuItem menuItemGrid;
+ private BaseItemView.ViewHolder parentViewHolder;
+
+ @Override
+ protected ItemView createItemView() {
+ return new JiveItemView(this, window.windowStyle);
+ }
+
+ @Override
+ public JiveItemView getItemView() {
+ return (JiveItemView) super.getItemView();
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ Bundle extras = checkNotNull(getIntent().getExtras(), "intent did not contain extras");
+ register = extras.getBoolean("register");
+ parent = extras.getParcelable(JiveItem.class.getName());
+ action = extras.getParcelable(Action.class.getName());
+
+ pluginViewDelegate = new JiveItemViewLogic(this);
+ setParentViewHolder();
+
+ // If initial setup is performed, use it
+ if (savedInstanceState != null && savedInstanceState.containsKey("window")) {
+ applyWindow((Window) savedInstanceState.getParcelable("window"));
+ } else {
+ if (parent != null && parent.window != null) {
+ applyWindow(parent.window);
+ } else if (parent != null && "playlist".equals(parent.getType())) {
+ // special case of playlist - override server based windowStyle to play_list
+ applyWindowStyle(Window.WindowStyle.PLAY_LIST);
+ } else
+ applyWindowStyle(Window.WindowStyle.TEXT_ONLY);
+ }
+
+ findViewById(R.id.input_view).setVisibility((hasInputField()) ? View.VISIBLE : View.GONE);
+ if (hasInputField()) {
+ MaterialButton inputButton = findViewById(R.id.input_button);
+ final EditText inputText = findViewById(R.id.plugin_input);
+ TextInputLayout inputTextLayout = findViewById(R.id.plugin_input_til);
+ int inputType = EditorInfo.TYPE_CLASS_TEXT;
+ int inputImage = R.drawable.keyboard_return;
+
+ switch (action.getInputType()) {
+ case TEXT:
+ break;
+ case SEARCH:
+ inputImage = R.drawable.ic_menu_search;
+ break;
+ case EMAIL:
+ inputType |= EditorInfo.TYPE_TEXT_VARIATION_EMAIL_ADDRESS;
+ break;
+ case PASSWORD:
+ inputType |= EditorInfo.TYPE_TEXT_VARIATION_PASSWORD;
+ break;
+ }
+ inputText.setInputType(inputType);
+ inputButton.setIconResource(inputImage);
+ inputTextLayout.setHint(parent.input.title);
+ inputText.setText(parent.input.initialText);
+ inputText.setOnKeyListener((v, keyCode, event) -> {
+ if ((event.getAction() == KeyEvent.ACTION_DOWN)
+ && (keyCode == KeyEvent.KEYCODE_ENTER)) {
+ clearAndReOrderItems(inputText.getText().toString());
+ return true;
+ }
+ return false;
+ });
+
+ inputButton.setOnClickListener(v -> {
+ if (getService() != null) {
+ clearAndReOrderItems(inputText.getText().toString());
+ }
+ });
+ }
+ }
+
+ private void setParentViewHolder() {
+ parentViewHolder = new BaseItemView.ViewHolder(this.findViewById(R.id.parent_container));
+ parentViewHolder.contextMenuButton.setOnClickListener(v -> pluginViewDelegate.showContextMenu(parentViewHolder, parent));
+ parentViewHolder.contextMenuButtonHolder.setTag(parentViewHolder);
+ }
+
+ @Override
+ protected void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ outState.putParcelable("window", window);
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ ArtworkListLayout listLayout = JiveItemView.listLayout(this, window.windowStyle);
+ AbsListView listView = getListView();
+ if ((listLayout == ArtworkListLayout.grid && !(listView instanceof GridView))
+ || (listLayout != ArtworkListLayout.grid && (listView instanceof GridView))) {
+ setListView(setupListView(listView));
+ }
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ getItemView().getLogicDelegate().resetContextMenu();
+ pluginViewDelegate.resetContextMenu();
+ }
+
+ @Override
+ protected AbsListView setupListView(AbsListView listView) {
+ ArtworkListLayout listLayout = JiveItemView.listLayout(this, window.windowStyle);
+ if (listLayout == ArtworkListLayout.grid && !(listView instanceof GridView)) {
+ listView = switchListView(listView, R.layout.item_grid);
+ }
+ if (listLayout != ArtworkListLayout.grid && (listView instanceof GridView)) {
+ listView = switchListView(listView, R.layout.item_list);
+ }
+ return super.setupListView(listView);
+ }
+
+ private AbsListView switchListView(AbsListView listView, @LayoutRes int resource) {
+ ViewGroup parent = (ViewGroup) listView.getParent();
+ int i1 = parent.indexOfChild(listView);
+ parent.removeViewAt(i1);
+ listView = (AbsListView) getLayoutInflater().inflate(resource, parent, false);
+ parent.addView(listView, i1);
+ return listView;
+ }
+
+ void updateHeader(String windowTitle) {
+ window.text = windowTitle;
+
+ parentViewHolder.itemView.setVisibility(View.VISIBLE);
+ parentViewHolder.text1.setText(windowTitle);
+ parentViewHolder.icon.setVisibility(View.GONE);
+ parentViewHolder.contextMenuButtonHolder.setVisibility(View.GONE);
+ }
+
+ void updateHeader(JiveItem parent) {
+ updateHeader(parent.getName());
+
+ if (parent.hasArtwork() && window.windowStyle == Window.WindowStyle.TEXT_ONLY) {
+ parentViewHolder.text2.setVisibility(View.VISIBLE);
+ parentViewHolder.text2.setText(parent.text2);
+
+ parentViewHolder.icon.setVisibility(View.VISIBLE);
+ ImageFetcher.getInstance(this).loadImage(parent.getIcon(), parentViewHolder.icon);
+ }
+ if (parent.hasContextMenu()) {
+ parentViewHolder.contextMenuButtonHolder.setVisibility(View.VISIBLE);
+ }
+
+ }
+
+ private void updateHeader(@NonNull Window window) {
+ if (!TextUtils.isEmpty(window.text)) {
+ updateHeader(window.text);
+ }
+ if (!TextUtils.isEmpty(window.textarea)) {
+ TextView header = findViewById(R.id.sub_header);
+ header.setText(window.textarea);
+ findViewById(R.id.sub_header_container).setVisibility(View.VISIBLE);
+ }
+ }
+
+ private void applyWindow(@NonNull Window window) {
+ applyWindowStyle(register ? Window.WindowStyle.TEXT_ONLY : window.windowStyle);
+ updateHeader(window);
+
+ window.titleStyle = this.window.titleStyle;
+ window.text = this.window.text;
+ this.window = window;
+ }
+
+
+ void applyWindowStyle(Window.WindowStyle windowStyle) {
+ applyWindowStyle(windowStyle, getItemView().listLayout());
+ }
+
+ void applyWindowStyle(Window.WindowStyle windowStyle, ArtworkListLayout prevListLayout) {
+ ArtworkListLayout listLayout = JiveItemView.listLayout(this, windowStyle);
+ updateViewMenuItems(listLayout, windowStyle);
+ if (windowStyle != window.windowStyle || listLayout != getItemView().listLayout()) {
+ window.windowStyle = windowStyle;
+ getItemView().setWindowStyle(windowStyle);
+ getItemAdapter().notifyDataSetChanged();
+ }
+ if (listLayout != prevListLayout) {
+ setListView(setupListView(getListView()));
+ }
+ }
+
+
+ private void clearAndReOrderItems(String inputString) {
+ if (getService() != null && !TextUtils.isEmpty(inputString)) {
+ parent.inputValue = inputString;
+ clearAndReOrderItems();
+ }
+ }
+
+ private boolean hasInputField() {
+ return parent != null && parent.hasInputField();
+ }
+
+ @Override
+ protected boolean needPlayer() {
+ // Most of the the times we actually do need a player, but if we need to register on SN,
+ // it is before we can get the players
+ return !register;
+ }
+
+ @Override
+ protected void orderPage(@NonNull ISqueezeService service, int start) {
+ if (parent != null) {
+ if (action == null || (parent.hasInput() && !parent.isInputReady())) {
+ showContent();
+ } else
+ service.pluginItems(start, parent, action, this);
+ } else if (register) {
+ service.register(this);
+ }
+ }
+
+ public void onEventMainThread(HandshakeComplete event) {
+ super.onEventMainThread(event);
+ if (parent != null && parent.hasSubItems()) {
+ getItemAdapter().update(parent.subItems.size(), 0, parent.subItems);
+ }
+ }
+
+ @Override
+ public void onItemsReceived(int count, int start, final Map parameters, List items, Class dataType) {
+ if (parameters.containsKey("goNow")) {
+ Action.NextWindow nextWindow = Action.NextWindow.fromString(Util.getString(parameters, "goNow"));
+ switch (nextWindow.nextWindow) {
+ case nowPlaying:
+ NowPlayingActivity.show(this);
+ break;
+ case playlist:
+ CurrentPlaylistActivity.show(this);
+ break;
+ case home:
+ HomeActivity.show(this);
+ break;
+ }
+ finish();
+ return;
+ }
+
+ final Window window = JiveItem.extractWindow(Util.getRecord(parameters, "window"), null);
+ if (window != null) {
+ // override server based icon_list style for playlist
+ if (window.windowStyle == Window.WindowStyle.ICON_LIST && parent != null && "playlist".equals(parent.getType())) {
+ window.windowStyle = Window.WindowStyle.PLAY_LIST;
+ }
+ runOnUiThread(() -> applyWindow(window));
+ }
+
+ if (this.window.text == null && parent != null) {
+ runOnUiThread(() -> updateHeader(parent));
+ }
+
+ // The documentation says "Returned with value 1 if there was a network error accessing
+ // the content source.". In practice (with at least the Napster and Pandora plugins) the
+ // value is an error message suitable for displaying to the user.
+ if (parameters.containsKey("networkerror")) {
+ Resources resources = getResources();
+ ISqueezeService service = getService();
+ String playerName;
+
+ if (service == null) {
+ playerName = "Unknown";
+ } else {
+ playerName = service.getActivePlayer().getName();
+ }
+
+ String errorMsg = Util.getString(parameters, "networkerror");
+
+ String errorMessage = String.format(resources.getString(R.string.server_error),
+ playerName, errorMsg);
+ NetworkErrorDialogFragment networkErrorDialogFragment =
+ NetworkErrorDialogFragment.newInstance(errorMessage);
+ networkErrorDialogFragment.show(getSupportFragmentManager(), "networkerror");
+ }
+
+ super.onItemsReceived(count, start, parameters, items, dataType);
+ }
+
+ @Override
+ public void action(JiveItem item, Action action, int alreadyPopped) {
+ if (getService() == null) {
+ return;
+ }
+
+ if (action != null) {
+ getService().action(item, action);
+ }
+
+ Action.JsonAction jAction = (action != null && action.action != null) ? action.action : null;
+ Action.NextWindow nextWindow = (jAction != null ? jAction.nextWindow : item.nextWindow);
+ nextWindow(nextWindow, alreadyPopped);
+ }
+
+ @Override
+ public void action(Action.JsonAction action, int alreadyPopped) {
+ if (getService() == null) {
+ return;
+ }
+
+ getService().action(action);
+ nextWindow(action.nextWindow, alreadyPopped);
+ }
+
+ private void nextWindow(Action.NextWindow nextWindow, int alreadyPopped) {
+ while (alreadyPopped > 0 && nextWindow != null) {
+ nextWindow = popNextWindow(nextWindow);
+ alreadyPopped--;
+ }
+ if (nextWindow != null) {
+ switch (nextWindow.nextWindow) {
+ case nowPlaying:
+ // Do nothing as now playing is always available in Squeezer (maybe toast the action)
+ break;
+ case playlist:
+ CurrentPlaylistActivity.show(this);
+ break;
+ case home:
+ HomeActivity.show(this);
+ break;
+ case parentNoRefresh:
+ finish();
+ break;
+ case grandparent:
+ setResult(Activity.RESULT_OK, new Intent(FINISH));
+ finish();
+ break;
+ case refresh:
+ clearAndReOrderItems();
+ break;
+ case parent:
+ case refreshOrigin:
+ setResult(Activity.RESULT_OK, new Intent(RELOAD));
+ finish();
+ break;
+ case windowId:
+ //TODO implement
+ break;
+ }
+ }
+ }
+
+ private Action.NextWindow popNextWindow(Action.NextWindow nextWindow) {
+ switch (nextWindow.nextWindow) {
+ case parent:
+ case parentNoRefresh:
+ return null;
+ case grandparent:
+ return new Action.NextWindow(Action.NextWindowEnum.parentNoRefresh);
+ case refreshOrigin:
+ return new Action.NextWindow(Action.NextWindowEnum.refresh);
+ default:
+ return nextWindow;
+
+ }
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+ if (requestCode == GO) {
+ if (resultCode == RESULT_OK) {
+ if (FINISH.equals(data.getAction())) {
+ finish();
+ } else if (RELOAD.equals(data.getAction())) {
+ clearAndReOrderItems();
+ }
+ }
+ }
+ }
+
+ public void setPreferredListLayout(ArtworkListLayout listLayout) {
+ ArtworkListLayout prevListLayout = getItemView().listLayout();
+ saveListLayout(listLayout);
+ applyWindowStyle(window.windowStyle, prevListLayout);
+ }
+
+ protected void saveListLayout(ArtworkListLayout listLayout) {
+ new Preferences(this).setAlbumListLayout(listLayout);
+ }
+
+ /**
+ * The user dismissed the network error dialog box. There's nothing more to do, so finish
+ * the activity.
+ */
+ @Override
+ public void onDialogDismissed(DialogInterface dialog) {
+ runOnUiThread(this::finish);
+ }
+
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ getMenuInflater().inflate(R.menu.pluginlistmenu, menu);
+ menuItemList = menu.findItem(R.id.menu_item_list);
+ menuItemGrid = menu.findItem(R.id.menu_item_grid);
+ return super.onCreateOptionsMenu(menu);
+ }
+
+ @Override
+ public boolean onPrepareOptionsMenu(Menu menu) {
+ updateViewMenuItems(getPreferredListLayout(), window.windowStyle);
+ return super.onPrepareOptionsMenu(menu);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ switch (item.getItemId()) {
+ case R.id.menu_item_list:
+ setPreferredListLayout(ArtworkListLayout.list);
+ return true;
+ case R.id.menu_item_grid:
+ setPreferredListLayout(ArtworkListLayout.grid);
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ private void updateViewMenuItems(ArtworkListLayout listLayout, Window.WindowStyle windowStyle) {
+ boolean canChangeListLayout = JiveItemView.canChangeListLayout(windowStyle);
+ if (menuItemList != null) {
+ menuItemList.setVisible(canChangeListLayout && listLayout != ArtworkListLayout.list);
+ menuItemGrid.setVisible(canChangeListLayout && listLayout != ArtworkListLayout.grid);
+ }
+ }
+
+
+ public static void register(Activity activity) {
+ final Intent intent = new Intent(activity, JiveItemListActivity.class);
+ intent.putExtra("register", true);
+ activity.startActivity(intent);
+ }
+
+ /**
+ * Start a new {@link JiveItemListActivity} to perform the supplied action.
+ *
+ * If the action requires input, we initially get the input.
+ *
+ * When input is ready or the action does not require input, items are ordered asynchronously
+ * via {@link ISqueezeService#pluginItems(int, JiveItem, Action, IServiceItemListCallback)}
+ *
+ * @see #orderPage(ISqueezeService, int)
+ */
+ public static void show(Activity activity, JiveItem parent, Action action) {
+ final Intent intent = getPluginListIntent(activity);
+ intent.putExtra(JiveItem.class.getName(), parent);
+ intent.putExtra(Action.class.getName(), action);
+ activity.startActivityForResult(intent, GO);
+ }
+
+ public static void show(Activity activity, JiveItem item) {
+ final Intent intent = getPluginListIntent(activity);
+ intent.putExtra(JiveItem.class.getName(), item);
+ activity.startActivityForResult(intent, GO);
+ }
+
+ @NonNull
+ private static Intent getPluginListIntent(Activity activity) {
+ Intent intent = new Intent(activity, JiveItemListActivity.class);
+ if (activity instanceof JiveItemListActivity && ((JiveItemListActivity)activity).register) {
+ intent.putExtra("register", true);
+ }
+ return intent;
+ }
+
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/JiveItemView.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/JiveItemView.java
new file mode 100644
index 000000000..a5bef1cfd
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/JiveItemView.java
@@ -0,0 +1,235 @@
+/*
+ * Copyright (c) 2011 Kurt Aaholst
+ *
+ * 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 uk.org.ngo.squeezer.itemlist;
+
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.SeekBar;
+import android.widget.TextView;
+
+import androidx.annotation.LayoutRes;
+
+import java.util.EnumSet;
+
+import uk.org.ngo.squeezer.R;
+import uk.org.ngo.squeezer.model.Action;
+import uk.org.ngo.squeezer.framework.BaseItemView;
+import uk.org.ngo.squeezer.framework.BaseListActivity;
+import uk.org.ngo.squeezer.framework.ItemListActivity;
+import uk.org.ngo.squeezer.model.JiveItem;
+import uk.org.ngo.squeezer.model.Slider;
+import uk.org.ngo.squeezer.model.Window;
+import uk.org.ngo.squeezer.itemlist.dialog.ArtworkListLayout;
+import uk.org.ngo.squeezer.util.ImageFetcher;
+
+public class JiveItemView extends BaseItemView {
+ private final JiveItemViewLogic logicDelegate;
+ private Window.WindowStyle windowStyle;
+
+ /** Width of the icon, if VIEW_PARAM_ICON is used. */
+ private int mIconWidth;
+
+ /** Height of the icon, if VIEW_PARAM_ICON is used. */
+ private int mIconHeight;
+
+ JiveItemView(BaseListActivity activity, Window.WindowStyle windowStyle) {
+ super(activity);
+ setWindowStyle(windowStyle);
+ this.logicDelegate = new JiveItemViewLogic(activity);
+ setLoadingViewParams(viewParamIcon() | VIEW_PARAM_TWO_LINE );
+ }
+
+ JiveItemViewLogic getLogicDelegate() {
+ return logicDelegate;
+ }
+
+ void setWindowStyle(Window.WindowStyle windowStyle) {
+ this.windowStyle = windowStyle;
+ if (listLayout() == ArtworkListLayout.grid) {
+ mIconWidth = getActivity().getResources().getDimensionPixelSize(R.dimen.album_art_icon_grid_width);
+ mIconHeight = getActivity().getResources().getDimensionPixelSize(R.dimen.album_art_icon_grid_height);
+ } else {
+ mIconWidth = getActivity().getResources().getDimensionPixelSize(R.dimen.album_art_icon_width);
+ mIconHeight = getActivity().getResources().getDimensionPixelSize(R.dimen.album_art_icon_height);
+ }
+ }
+
+ @Override
+ public View getAdapterView(View convertView, ViewGroup parent, int position, final JiveItem item, boolean selected) {
+ if (item.radio != null) {
+ item.radio = selected;
+ }
+ if (item.hasSlider()) {
+ return sliderView(parent, item);
+ } else {
+ @ViewParam int viewParams = (viewParamIcon() | VIEW_PARAM_TWO_LINE | viewParamContext(item));
+ View view = getAdapterView(convertView, parent, position, viewParams);
+ bindView(view, item);
+ return view;
+ }
+ }
+
+ private View sliderView(ViewGroup parent, final JiveItem item) {
+ View view = getLayoutInflater().inflate(R.layout.slider_item, parent, false);
+ final TextView sliderValue = view.findViewById(R.id.slider_value);
+ SeekBar seekBar = view.findViewById(R.id.slider);
+ final int thumbWidth = seekBar.getThumb().getIntrinsicWidth();
+ final int thumbOffset = seekBar.getThumbOffset();
+ final Slider slider = item.slider;
+ final int max = seekBar.getMax();
+ seekBar.setProgress((slider.initial - slider.min) * max / (slider.max - slider.min));
+ seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
+ @Override
+ public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
+ sliderValue.setText(String.valueOf(getValue(progress)));
+
+ int pos = progress * (seekBar.getWidth() - 2 * thumbWidth) / seekBar.getMax();
+ sliderValue.setX(seekBar.getX() + pos + thumbOffset + thumbWidth);
+ }
+
+ @Override
+ public void onStartTrackingTouch(SeekBar seekBar) {
+ sliderValue.setVisibility(View.VISIBLE);
+ }
+
+ @Override
+ public void onStopTrackingTouch(SeekBar seekBar) {
+ if (item.goAction != null) {
+ sliderValue.setVisibility(View.INVISIBLE);
+ item.inputValue = String.valueOf(getValue(seekBar.getProgress()));
+ getActivity().action(item, item.goAction);
+ }
+ }
+
+ private int getValue(int progress) {
+ return slider.min + (slider.max - slider.min) * progress / max;
+ }
+ });
+ return view;
+ }
+
+ @Override
+ public View getAdapterView(View convertView, ViewGroup parent, int position, @ViewParam int viewParams) {
+ return getAdapterView(convertView, parent, position, viewParams, layoutResource());
+ }
+
+ @LayoutRes private int layoutResource() {
+ return (listLayout() == ArtworkListLayout.grid) ? R.layout.grid_item : R.layout.list_item;
+ }
+
+ ArtworkListLayout listLayout() {
+ return listLayout(getActivity(), windowStyle);
+ }
+
+ static ArtworkListLayout listLayout(ItemListActivity activity, Window.WindowStyle windowStyle) {
+ if (canChangeListLayout(windowStyle)) {
+ return activity.getPreferredListLayout();
+ }
+ return ArtworkListLayout.list;
+ }
+
+ static boolean canChangeListLayout(Window.WindowStyle windowStyle) {
+ return EnumSet.of(Window.WindowStyle.HOME_MENU, Window.WindowStyle.ICON_LIST).contains(windowStyle);
+ }
+
+ private int viewParamIcon() {
+ return windowStyle == Window.WindowStyle.TEXT_ONLY ? 0 : VIEW_PARAM_ICON;
+ }
+
+ private int viewParamContext(JiveItem item) {
+ return item.hasContextMenu() ? VIEW_PARAM_CONTEXT_BUTTON : 0;
+ }
+
+ @Override
+ public void bindView(View view, JiveItem item) {
+ super.bindView(view, item);
+ ViewHolder viewHolder = (ViewHolder) view.getTag();
+
+ viewHolder.text2.setText(item.text2);
+
+ // If the item has an image, then fetch and display it
+ if (item.hasArtwork()) {
+ ImageFetcher.getInstance(getActivity()).loadImage(
+ item.getIcon(),
+ viewHolder.icon,
+ mIconWidth,
+ mIconHeight,
+ () -> onIcon(viewHolder)
+
+ );
+ } else {
+ viewHolder.icon.setImageDrawable(item.getIconDrawable(getActivity()));
+ onIcon(viewHolder);
+ }
+
+ if (item.hasContextMenu()) {
+ viewHolder.contextMenuButton.setVisibility(item.checkbox == null && item.radio == null ? View.VISIBLE : View.GONE);
+ viewHolder.contextMenuCheckbox.setVisibility(item.checkbox != null ? View.VISIBLE : View.GONE);
+ viewHolder.contextMenuRadio.setVisibility(item.radio != null ? View.VISIBLE : View.GONE);
+ if (item.checkbox != null) {
+ viewHolder.contextMenuCheckbox.setChecked(item.checkbox);
+ } else if (item.radio != null) {
+ viewHolder.contextMenuRadio.setChecked(item.radio);
+ }
+ }
+ }
+
+ protected void onIcon(ViewHolder viewHolder) {
+ }
+
+ @Override
+ public boolean isSelectable(JiveItem item) {
+ return item.isSelectable();
+ }
+
+ @Override
+ public boolean isSelected(JiveItem item) {
+ return item.radio != null && item.radio;
+ }
+
+ @Override
+ public boolean onItemSelected(View view, int index, JiveItem item) {
+ Action.JsonAction action = (item.goAction != null && item.goAction.action != null) ? item.goAction.action : null;
+ Action.NextWindow nextWindow = (action != null ? action.nextWindow : item.nextWindow);
+ if (item.checkbox != null) {
+ item.checkbox = !item.checkbox;
+ Action checkboxAction = item.checkboxActions.get(item.checkbox);
+ if (checkboxAction != null) {
+ getActivity().action(item, checkboxAction);
+ }
+ ViewHolder viewHolder = (ViewHolder) view.getTag();
+ viewHolder.contextMenuCheckbox.setChecked(item.checkbox);
+ } else if (nextWindow != null && !item.hasInput()) {
+ getActivity().action(item, item.goAction);
+ } else {
+ if (item.goAction != null)
+ logicDelegate.execGoAction((ViewHolder) view.getTag(), item, 0);
+ else if (item.hasSubItems())
+ JiveItemListActivity.show(getActivity(), item);
+ else if (item.getNode() != null) {
+ HomeMenuActivity.show(getActivity(), item);
+ }
+ }
+
+ return (item.radio != null);
+ }
+
+ @Override
+ public void showContextMenu(ViewHolder viewHolder, JiveItem item) {
+ logicDelegate.showContextMenu(viewHolder, item);
+ }
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/JiveItemViewLogic.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/JiveItemViewLogic.java
new file mode 100644
index 000000000..d2a485964
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/JiveItemViewLogic.java
@@ -0,0 +1,241 @@
+/*
+ * Copyright (c) 2019 Kurt Aaholst
+ *
+ * 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 uk.org.ngo.squeezer.itemlist;
+
+import android.app.Activity;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.PopupMenu;
+
+import java.util.List;
+import java.util.Map;
+
+import uk.org.ngo.squeezer.Preferences;
+import uk.org.ngo.squeezer.R;
+import uk.org.ngo.squeezer.model.Action;
+import uk.org.ngo.squeezer.framework.BaseActivity;
+import uk.org.ngo.squeezer.framework.BaseItemView;
+import uk.org.ngo.squeezer.model.Item;
+import uk.org.ngo.squeezer.itemlist.dialog.ArtworkDialog;
+import uk.org.ngo.squeezer.itemlist.dialog.ChoicesDialog;
+import uk.org.ngo.squeezer.itemlist.dialog.InputTextDialog;
+import uk.org.ngo.squeezer.itemlist.dialog.InputTimeDialog;
+import uk.org.ngo.squeezer.itemlist.dialog.SlideShow;
+import uk.org.ngo.squeezer.model.JiveItem;
+import uk.org.ngo.squeezer.service.ISqueezeService;
+
+/**
+ * Delegate with view logic for {@link JiveItem} which can be used from any {@link BaseActivity}
+ */
+public class JiveItemViewLogic implements IServiceItemListCallback, PopupMenu.OnDismissListener {
+ private final BaseActivity activity;
+
+ public JiveItemViewLogic(BaseActivity activity) {
+ this.activity = activity;
+ }
+
+ /**
+ * Perform the go action of the supplied item.
+ *
+ * If this is a do action and it doesn't require input, it is performed immediately
+ * by calling {@link BaseActivity#action(JiveItem, Action) }.
+ *
+ * Otherwise we pass the action to a sub activity (window in slim terminology) which
+ * collects the input if required and performs the action. See {@link JiveItemListActivity#show(Activity, JiveItem, Action)}
+ *
+ * Finally if the (unsupported) "showBigArtwork" flag is present in an item the do
+ * action will return an artwork id or URL, which can be used the fetch an image to display in a
+ * popup. See {@link ArtworkDialog#show(BaseActivity, Action)}
+ */
+ void execGoAction(BaseItemView.ViewHolder viewHolder, JiveItem item, int alreadyPopped) {
+ if (item.showBigArtwork) {
+ ArtworkDialog.show(activity, item.goAction);
+ } else if (item.goAction.isSlideShow()) {
+ SlideShow.show(activity, item.goAction);
+ } else if (item.goAction.isContextMenu()) {
+ showContextMenu(viewHolder, item, item.goAction);
+ } else if (item.doAction) {
+ if (item.hasInput()) {
+ if (item.hasChoices()) {
+ ChoicesDialog.show(activity, item, alreadyPopped);
+ } else if ("time".equals(item.input.inputStyle)) {
+ InputTimeDialog.show(activity, item, alreadyPopped);
+ } else {
+ InputTextDialog.show(activity, item, alreadyPopped);
+ }
+ } else {
+ activity.action(item, item.goAction, alreadyPopped);
+ }
+ } else {
+ JiveItemListActivity.show(activity, item, item.goAction);
+ }
+ }
+
+ // Only touch these from the main thread
+ private int contextStack = 0;
+ private JiveItem contextMenuItem;
+ private PopupMenu contextPopup;
+ private BaseItemView.ViewHolder contextMenuViewHolder;
+
+ public void showContextMenu(BaseItemView.ViewHolder viewHolder, JiveItem item) {
+ if (item.moreAction != null) {
+ showContextMenu(viewHolder, item, item.moreAction);
+ } else {
+ showStandardContextMenu(viewHolder.contextMenuButtonHolder, item);
+ }
+ }
+
+ private void showContextMenu(BaseItemView.ViewHolder viewHolder, JiveItem item, Action action) {
+ contextMenuViewHolder = viewHolder;
+ contextStack = 1;
+ contextMenuItem = item;
+ orderContextMenu(action);
+ }
+
+ private void showStandardContextMenu(View v, final JiveItem item) {
+ contextPopup = new PopupMenu(activity, v);
+ Menu menu = contextPopup.getMenu();
+
+ if (item.playAction != null) {
+ menu.add(Menu.NONE, R.id.play_now, Menu.NONE, R.string.PLAY_NOW);
+ }
+ if (item.addAction != null) {
+ menu.add(Menu.NONE, R.id.add_to_playlist, Menu.NONE, R.string.ADD_TO_END);
+ }
+ if (item.insertAction != null) {
+ menu.add(Menu.NONE, R.id.play_next, Menu.NONE, R.string.PLAY_NEXT);
+ }
+ if (item.moreAction != null) {
+ menu.add(Menu.NONE, R.id.more, Menu.NONE, R.string.MORE);
+ }
+
+ contextPopup.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() {
+ @Override
+ public boolean onMenuItemClick(MenuItem menuItem) {
+ return doStandardItemContext(menuItem, item);
+ }
+ });
+ contextPopup.setOnDismissListener(this);
+ contextPopup.show();
+ }
+
+ private boolean doStandardItemContext(MenuItem menuItem, JiveItem item) {
+ switch (menuItem.getItemId()) {
+ case R.id.play_now:
+ activity.action(item, item.playAction);
+ return true;
+ case R.id.add_to_playlist:
+ activity.action(item, item.addAction);
+ return true;
+ case R.id.play_next:
+ activity.action(item, item.insertAction);
+ return true;
+ case R.id.more:
+ JiveItemListActivity.show(activity, item, item.moreAction);
+ return true;
+ }
+ return false;
+ }
+
+ private void showContextMenu(final BaseItemView.ViewHolder viewHolder, final List items) {
+ Preferences preferences = new Preferences(activity);
+ contextPopup = new PopupMenu(activity, viewHolder.contextMenuButtonHolder);
+ Menu menu = contextPopup.getMenu();
+
+ int index = 0;
+ if (preferences.isDownloadEnabled() && contextMenuItem != null && contextMenuItem.canDownload()) {
+ menu.add(Menu.NONE, index++, Menu.NONE, R.string.DOWNLOAD);
+ }
+ final int offset = index;
+ for (JiveItem jiveItem : items) {
+ menu.add(Menu.NONE, index++, Menu.NONE, jiveItem.getName()).setEnabled(jiveItem.goAction != null);
+ }
+
+ contextPopup.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() {
+ @Override
+ public boolean onMenuItemClick(MenuItem menuItem) {
+ if (menuItem.getItemId() < offset) {
+ activity.downloadItem(contextMenuItem);
+ } else {
+ doItemContext(viewHolder, items.get(menuItem.getItemId() - offset));
+ }
+ return true;
+ }
+ });
+ contextPopup.setOnDismissListener(this);
+ contextPopup.show();
+ }
+
+ private void doItemContext(BaseItemView.ViewHolder viewHolder, JiveItem item) {
+ Action.NextWindow nextWindow = (item.goAction != null ? item.goAction.action.nextWindow : item.nextWindow);
+ if (nextWindow != null) {
+ activity.action(item, item.goAction, contextStack);
+ } else {
+ execGoAction(viewHolder, item, contextStack);
+ }
+ }
+
+ private void orderContextMenu(Action action) {
+ ISqueezeService service = activity.getService();
+ if (service != null) {
+ contextMenuViewHolder.contextMenuButton.setVisibility(View.GONE);
+ contextMenuViewHolder.contextMenuLoading.setVisibility(View.VISIBLE);
+ service.pluginItems(action, this);
+ }
+ }
+
+ @Override
+ public Object getClient() {
+ return activity;
+ }
+
+ @Override
+ public void onItemsReceived(int count, int start, final Map parameters, final List items, Class dataType) {
+ activity.runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ // If #resetContextMenu has been called while we were in the main looper #contextMenuViewHolder will be null, so skip the items
+ if (contextMenuViewHolder != null) {
+ contextMenuViewHolder.contextMenuButton.setVisibility(View.VISIBLE);
+ contextMenuViewHolder.contextMenuLoading.setVisibility(View.GONE);
+ showContextMenu(contextMenuViewHolder, items);
+ }
+ }
+ });
+ }
+
+ public void resetContextMenu() {
+ if (contextMenuViewHolder != null) {
+ contextMenuViewHolder.contextMenuButton.setVisibility(View.VISIBLE);
+ contextMenuViewHolder.contextMenuLoading.setVisibility(View.GONE);
+ }
+
+ if (contextPopup != null) {
+ contextPopup.dismiss();
+ contextPopup = null;
+ }
+
+ contextStack = 0;
+ contextMenuViewHolder = null;
+ }
+
+ @Override
+ public void onDismiss(PopupMenu menu) {
+ contextPopup = null;
+ }
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/ListDragListener.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/ListDragListener.java
new file mode 100644
index 000000000..a396aa7b9
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/ListDragListener.java
@@ -0,0 +1,136 @@
+/*
+ * Copyright (c) 2020 Kurt Aaholst
+ *
+ * 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 uk.org.ngo.squeezer.itemlist;
+
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import android.view.DragEvent;
+import android.view.View;
+import android.widget.AbsListView;
+import android.widget.AdapterView;
+
+import androidx.annotation.NonNull;
+
+import uk.org.ngo.squeezer.R;
+
+class ListDragListener extends Handler implements View.OnDragListener {
+ private static final int MSG_SCROLL_ENDED = 1;
+
+ private final CurrentPlaylistActivity activity;
+ private int viewPosition;
+ private int itemPosition;
+ private boolean scrolling;
+ private int scrollSpeed;
+ private float lastMoveY = -1;
+
+ public ListDragListener(CurrentPlaylistActivity activity) {
+ super(Looper.getMainLooper());
+ this.activity = activity;
+ }
+
+ @Override
+ public boolean onDrag(View v, DragEvent event) {
+ switch(event.getAction()) {
+
+ case DragEvent.ACTION_DRAG_STARTED:
+ itemPosition = viewPosition = getPosition(event);
+ return true;
+
+ case DragEvent.ACTION_DRAG_ENTERED:
+ return true;
+
+ case DragEvent.ACTION_DRAG_LOCATION: {
+ int position = getPosition(event);
+ // Move the highlighted song if necessary
+ // Don't move to a position that is not yet loaded, because it will be overridden on load.
+ if (position != AdapterView.INVALID_POSITION && position != viewPosition && activity.getItemAdapter().getItem(position) != null) {
+ // Prevent moving back if we have just swapped the current drag item with a taller item
+ if (lastMoveY == -1 || !((event.getY() > lastMoveY && position < viewPosition) || (event.getY() < lastMoveY && position > viewPosition))) {
+ int selectedIndex = activity.getItemAdapter().getSelectedIndex();
+ if (selectedIndex == viewPosition) {
+ activity.getItemAdapter().setSelectedIndex(position);
+ } else if (viewPosition < selectedIndex && position >= selectedIndex) {
+ activity.getItemAdapter().setSelectedIndex(selectedIndex - 1);
+ } else if (viewPosition > selectedIndex && position <= selectedIndex) {
+ activity.getItemAdapter().setSelectedIndex(selectedIndex + 1);
+ }
+
+ activity.getItemAdapter().moveItem(viewPosition, position);
+ activity.setDraggedIndex(position);
+ viewPosition = position;
+ lastMoveY = event.getY();
+ }
+ }
+ setScrollSpeed(event);
+ return true;
+ }
+ case DragEvent.ACTION_DRAG_EXITED:
+ return true;
+
+ case DragEvent.ACTION_DROP: {
+ return true;
+ }
+ case DragEvent.ACTION_DRAG_ENDED:
+ activity.setDraggedIndex(-1);
+ scrollSpeed = 0;
+ lastMoveY = -1;
+ if (viewPosition != itemPosition) {
+ activity.getService().playlistMove(itemPosition, viewPosition);
+ activity.skipPlaylistChanged();
+ }
+ return true;
+ }
+
+ return false;
+ }
+
+ private void startScroll(int scrollSpeed) {
+ scrolling = true;
+ lastMoveY = -1;
+ int distance = activity.getResources().getDimensionPixelSize(R.dimen.playlist_scroll_distance);
+ int duration = 250 - Math.abs(scrollSpeed);
+ activity.getListView().smoothScrollBy(scrollSpeed < 0 ? -distance : distance, duration);
+ removeMessages(MSG_SCROLL_ENDED);
+ sendEmptyMessageDelayed(MSG_SCROLL_ENDED, duration-10);
+ }
+
+ private void setScrollSpeed(DragEvent event) {
+ scrollSpeed = getScrollSpeed(event);
+ if (scrollSpeed != 0 && !scrolling) {
+ startScroll(scrollSpeed);
+ }
+ }
+
+ private int getPosition(DragEvent event) {
+ AbsListView listView = activity.getListView();
+ return listView.pointToPosition((int) (event.getX()), (int) (event.getY()));
+ }
+
+ private int getScrollSpeed(DragEvent event) {
+ int perMille = (int) (event.getY() / activity.getListView().getHeight() * 1000);
+ return (perMille < 200 ? (perMille - 200) : perMille > 800 ? (perMille - 800) : 0);
+ }
+
+ @Override
+ public void handleMessage(@NonNull Message msg) {
+ scrolling = false;
+ if (scrollSpeed != 0) {
+ startScroll(scrollSpeed);
+ }
+ }
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/PlayerBaseView.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/PlayerBaseView.java
new file mode 100644
index 000000000..6947fadb1
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/PlayerBaseView.java
@@ -0,0 +1,55 @@
+package uk.org.ngo.squeezer.itemlist;
+
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.LayoutRes;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import uk.org.ngo.squeezer.R;
+import uk.org.ngo.squeezer.framework.BaseItemView;
+import uk.org.ngo.squeezer.model.Player;
+
+
+public abstract class PlayerBaseView extends BaseItemView {
+ private static final Map modelIcons = PlayerBaseView.initializeModelIcons();
+ protected final A activity;
+ private @LayoutRes
+ int layoutResource;
+
+ public PlayerBaseView(A activity, @LayoutRes int layoutResource) {
+ super(activity);
+ this.activity = activity;
+ this.layoutResource = layoutResource;
+ }
+
+ private static Map initializeModelIcons() {
+ Map modelIcons = new HashMap<>();
+ modelIcons.put("baby", R.drawable.ic_baby);
+ modelIcons.put("boom", R.drawable.ic_boom);
+ modelIcons.put("fab4", R.drawable.ic_fab4);
+ modelIcons.put("receiver", R.drawable.ic_receiver);
+ modelIcons.put("controller", R.drawable.ic_controller);
+ modelIcons.put("sb1n2", R.drawable.ic_sb1n2);
+ modelIcons.put("sb3", R.drawable.ic_sb3);
+ modelIcons.put("slimp3", R.drawable.ic_slimp3);
+ modelIcons.put("softsqueeze", R.drawable.ic_softsqueeze);
+ modelIcons.put("squeezeplay", R.drawable.ic_squeezeplay);
+ modelIcons.put("transporter", R.drawable.ic_transporter);
+ modelIcons.put("squeezeplayer", R.drawable.ic_squeezeplayer);
+ return modelIcons;
+ }
+
+ protected static int getModelIcon(String model) {
+ Integer icon = modelIcons.get(model);
+ return (icon != null ? icon : R.drawable.ic_blank);
+ }
+
+ @Override
+ public View getAdapterView(View convertView, ViewGroup parent, int position, @ViewParam int viewParams) {
+ return getAdapterView(convertView, parent, position, viewParams, layoutResource);
+ }
+
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/PlayerListActivity.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/PlayerListActivity.java
new file mode 100644
index 000000000..8db6b65c5
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/PlayerListActivity.java
@@ -0,0 +1,140 @@
+/*
+ * Copyright (c) 2011 Kurt Aaholst
+ *
+ * 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 uk.org.ngo.squeezer.itemlist;
+
+import android.os.Bundle;
+
+import androidx.annotation.NonNull;
+
+import java.util.List;
+
+import uk.org.ngo.squeezer.R;
+import uk.org.ngo.squeezer.itemlist.dialog.DefeatDestructiveTouchToPlayDialog;
+import uk.org.ngo.squeezer.itemlist.dialog.PlayTrackAlbumDialog;
+import uk.org.ngo.squeezer.itemlist.dialog.PlayerSyncDialog;
+import uk.org.ngo.squeezer.model.Item;
+import uk.org.ngo.squeezer.model.Player;
+import uk.org.ngo.squeezer.service.ISqueezeService;
+import uk.org.ngo.squeezer.service.event.PlayerVolume;
+
+
+public class PlayerListActivity extends PlayerListBaseActivity implements
+ PlayerSyncDialog.PlayerSyncDialogHost,
+ PlayTrackAlbumDialog.PlayTrackAlbumDialogHost,
+ DefeatDestructiveTouchToPlayDialog.DefeatDestructiveTouchToPlayDialogHost {
+ private static final String CURRENT_PLAYER = "currentPlayer";
+
+ private Player currentPlayer;
+
+ @Override
+ protected void onSaveInstanceState(Bundle outState) {
+ outState.putParcelable(CURRENT_PLAYER, currentPlayer);
+ super.onSaveInstanceState(outState);
+ }
+
+ @Override
+ protected boolean needPlayer() {
+ return false;
+ }
+
+
+
+ public void onEventMainThread(PlayerVolume event) {
+ if (!mTrackingTouch) {
+ mResultsAdapter.notifyDataSetChanged();
+ }
+ }
+
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ setContentView(R.layout.item_list_players);
+
+ if (savedInstanceState != null)
+ currentPlayer = savedInstanceState.getParcelable(PlayerListActivity.CURRENT_PLAYER);
+ }
+
+ @Override
+ public Player getCurrentPlayer() {
+ return currentPlayer;
+ }
+
+ public void setCurrentPlayer(Player currentPlayer) {
+ this.currentPlayer = currentPlayer;
+ }
+
+ public void playerRename(String newName) {
+ ISqueezeService service = getService();
+ if (service == null) {
+ return;
+ }
+
+ service.playerRename(currentPlayer, newName);
+ this.currentPlayer.setName(newName);
+ mResultsAdapter.notifyDataSetChanged();
+ }
+
+ public PlayerBaseView createPlayerView() {
+ return new PlayerView(this);
+ }
+
+ /**
+ * Synchronises the slave player to the player with masterId.
+ *
+ * @param slave the player to sync.
+ * @param masterId ID of the player to sync to.
+ */
+ @Override
+ public void syncPlayerToPlayer(@NonNull Player slave, @NonNull String masterId) {
+ getService().syncPlayerToPlayer(slave, masterId);
+ }
+
+ /**
+ * Removes the player from any sync groups.
+ *
+ * @param player the player to be removed from sync groups.
+ */
+ @Override
+ public void unsyncPlayer(@NonNull Player player) {
+ getService().unsyncPlayer(player);
+ }
+
+ @Override
+ public String getPlayTrackAlbum() {
+ return currentPlayer.getPlayerState().prefs.get(Player.Pref.PLAY_TRACK_ALBUM);
+ }
+
+ @Override
+ public void setPlayTrackAlbum(@NonNull String option) {
+ getService().playerPref(currentPlayer, Player.Pref.PLAY_TRACK_ALBUM, option);
+ }
+
+ @Override
+ public String getDefeatDestructiveTTP() {
+ return currentPlayer.getPlayerState().prefs.get(Player.Pref.DEFEAT_DESTRUCTIVE_TTP);
+ }
+
+ @Override
+ public void setDefeatDestructiveTTP(@NonNull String option) {
+ getService().playerPref(currentPlayer, Player.Pref.DEFEAT_DESTRUCTIVE_TTP, option);
+ }
+
+ @Override
+ protected void updateAdapter(int count, int start, List items, Class dataType) {
+
+ }
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/PlayerListAdapter.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/PlayerListAdapter.java
new file mode 100644
index 000000000..0e23ef1c8
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/PlayerListAdapter.java
@@ -0,0 +1,218 @@
+/*
+ * Copyright (c) 2014 Kurt Aaholst
+ *
+ * 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 uk.org.ngo.squeezer.itemlist;
+
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseExpandableListAdapter;
+import android.widget.TextView;
+
+import com.google.common.base.Joiner;
+import com.google.common.collect.HashMultimap;
+import com.google.common.collect.Multimap;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+import uk.org.ngo.squeezer.R;
+import uk.org.ngo.squeezer.framework.ItemAdapter;
+import uk.org.ngo.squeezer.model.CurrentPlaylistItem;
+import uk.org.ngo.squeezer.model.Player;
+
+public class PlayerListAdapter extends BaseExpandableListAdapter {
+ private final PlayerListBaseActivity mActivity;
+
+ private final List mChildAdapters = new ArrayList<>();
+
+ /**
+ * A list adapter for a synchronization group, containing players.
+ * This class is comparable and it has a name for the synchronization group.
+ */
+ private class SyncGroup extends ItemAdapter implements Comparable {
+
+ public String syncGroupName; // the name of the synchronization group as displayed in the players screen
+
+ public SyncGroup(PlayerBaseView playerView) {
+ super(playerView);
+ }
+
+ @Override
+ public int compareTo(Object otherSyncGroup) {
+ // compare this syncgroup name with the other one, alphabetically
+ return this.syncGroupName.compareToIgnoreCase(((SyncGroup)otherSyncGroup).syncGroupName);
+ }
+
+ @Override
+ public void update(int count, int start, List syncedPlayersList) {
+ Collections.sort(syncedPlayersList); // first order players in syncgroup alphabetically
+
+ // add the list
+ super.update(count, start, syncedPlayersList);
+
+ // determine and set synchronization group name (player names divided by commas)
+ List playerNames = new ArrayList<>();
+ for (int i = 0; i < this.getCount(); i++) {
+ Player p = this.getItem(i);
+ playerNames.add(p.getName());
+ }
+ syncGroupName = Joiner.on(", ").join(playerNames);
+ }
+
+ }
+ /** The last set of player sync groups that were provided. */
+ private Multimap prevPlayerSyncGroups;
+
+ /** Indicates if the list of players has changed. */
+ boolean mPlayersChanged;
+
+ /** Joins elements together with ' - ', skipping nulls. */
+ private static final Joiner mJoiner = Joiner.on(" - ").skipNulls();
+
+ /** Count of how many players are in the adapter. */
+ int mPlayerCount;
+
+ public PlayerListAdapter(PlayerListBaseActivity activity) {
+ mActivity = activity;
+ }
+
+
+ public void onGroupClick(View view, int groupPosition) {
+ mChildAdapters.get(groupPosition).onSelected(view);
+ }
+ public void onChildClick(View view, int groupPosition, int childPosition) {
+ mChildAdapters.get(groupPosition).onItemSelected(view, childPosition);
+ }
+
+ public void clear() {
+ mPlayersChanged = true;
+ mChildAdapters.clear();
+ mPlayerCount = 0;
+ notifyDataSetChanged();
+ }
+
+ /**
+ * Sets the players in to the adapter.
+ *
+ * @param playerSyncGroups Multimap, mapping from the player ID of the syncmaster to the
+ * Players synced to that master. See
+ * {@link PlayerListActivity#updateSyncGroups(Collection)} for how this map is
+ * generated.
+ */
+ void setSyncGroups(Multimap playerSyncGroups) {
+ // The players might not have changed (so there's no need to reset the contents of the
+ // adapter) but information about an individual player might have done.
+ if (prevPlayerSyncGroups != null && prevPlayerSyncGroups.equals(playerSyncGroups)) {
+ notifyDataSetChanged();
+ return;
+ }
+
+ prevPlayerSyncGroups = HashMultimap.create(playerSyncGroups);
+ clear();
+
+ // Get a list of slaves for every synchronization group
+ for (Collection slaves: playerSyncGroups.asMap().values()) {
+ // create a new synchronization group
+ SyncGroup syncGroup = new SyncGroup(mActivity.createPlayerView());
+ mPlayerCount += slaves.size();
+ // add the slaves (the players) to the synchronization group
+ syncGroup.update(slaves.size(), 0, new ArrayList<>(slaves));
+ // add synchronization group to the child adapters
+ mChildAdapters.add(syncGroup);
+ }
+ Collections.sort(mChildAdapters); // sort syncgroup list alphabetically by syncgroup name
+ notifyDataSetChanged();
+ }
+
+ @Override
+ public boolean areAllItemsEnabled() {
+ return true; // Should be false, but then there is no divider
+ }
+
+ @Override
+ public View getChildView(int groupPosition, int childPosition, boolean isLastChild, View convertView, ViewGroup parent) {
+ return mChildAdapters.get(groupPosition).getView(childPosition, convertView, parent);
+ }
+
+ @Override
+ public int getGroupCount() {
+ return mChildAdapters.size();
+ }
+
+ @Override
+ public int getChildrenCount(int groupPosition) {
+ return mChildAdapters.get(groupPosition).getCount();
+ }
+
+ @Override
+ public Object getGroup(int groupPosition) {
+ return mChildAdapters.get(groupPosition);
+ }
+
+ @Override
+ public Object getChild(int groupPosition, int childPosition) {
+ return mChildAdapters.get(groupPosition).getItem(childPosition);
+ }
+
+ /**
+ * Use the ID of the first player in the group as the identifier for the group.
+ *
+ * {@inheritDoc}
+ * @param groupPosition
+ * @return
+ */
+ @Override
+ public long getGroupId(int groupPosition) {
+ return mChildAdapters.get(groupPosition).getItem(0).getIdAsLong();
+ }
+
+ @Override
+ public long getChildId(int groupPosition, int childPosition) {
+ return mChildAdapters.get(groupPosition).getItem(childPosition).getIdAsLong();
+ }
+
+ @Override
+ public boolean hasStableIds() {
+ return true;
+ }
+
+ @Override
+ public View getGroupView(int groupPosition, boolean isExpanded, View convertView, ViewGroup parent) {
+ View row = mActivity.getLayoutInflater().inflate(R.layout.group_player, parent, false);
+
+ TextView text1 = row.findViewById(R.id.text1);
+ TextView text2 = row.findViewById(R.id.text2);
+
+ SyncGroup syncGroup = mChildAdapters.get(groupPosition);
+ String header = syncGroup.syncGroupName;
+ text1.setText(mActivity.getString(R.string.player_group_header, header));
+
+ CurrentPlaylistItem groupSong = syncGroup.getItem(0).getPlayerState().getCurrentSong();
+
+ if (groupSong != null) {
+ text2.setText(mJoiner.join(groupSong.getName(), groupSong.getArtist(),
+ groupSong.getAlbum()));
+ }
+ return row;
+ }
+
+ @Override
+ public boolean isChildSelectable(int groupPosition, int childPosition) {
+ return false;
+ }
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/PlayerListBaseActivity.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/PlayerListBaseActivity.java
new file mode 100644
index 000000000..404a9cc28
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/PlayerListBaseActivity.java
@@ -0,0 +1,197 @@
+package uk.org.ngo.squeezer.itemlist;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.util.Log;
+import android.view.View;
+import android.widget.AbsListView;
+import android.widget.ExpandableListView;
+
+import androidx.annotation.NonNull;
+
+import com.google.common.collect.HashMultimap;
+import com.google.common.collect.Multimap;
+
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+
+import uk.org.ngo.squeezer.framework.ItemListActivity;
+import uk.org.ngo.squeezer.model.Player;
+import uk.org.ngo.squeezer.model.PlayerState;
+import uk.org.ngo.squeezer.service.ISqueezeService;
+import uk.org.ngo.squeezer.service.event.HandshakeComplete;
+import uk.org.ngo.squeezer.service.event.PlayerStateChanged;
+
+
+public abstract class PlayerListBaseActivity extends ItemListActivity {
+ private static final String TAG = PlayerListBaseActivity.class.getName();
+
+ /**
+ * Map from player IDs to Players synced to that player ID.
+ */
+ private final Multimap mPlayerSyncGroups = HashMultimap.create();
+ protected boolean mTrackingTouch;
+ /**
+ * An update arrived while tracking touches. UI should be re-synced.
+ */
+ protected boolean mUpdateWhileTracking = false;
+ PlayerListAdapter mResultsAdapter;
+ private ExpandableListView mResultsExpandableListView;
+
+ public static void show(Context context) {
+ final Intent intent = new Intent(context, PlayerListActivity.class)
+ .addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT);
+ context.startActivity(intent);
+ }
+
+ /**
+ * Updates the adapter with the current players, and ensures that the list view is
+ * expanded.
+ */
+ protected void updateAndExpandPlayerList() {
+ // Can't do anything if the adapter hasn't been set (pre-handshake).
+ if (mResultsExpandableListView.getAdapter() == null) {
+ return;
+ }
+
+ updateSyncGroups(getService().getPlayers());
+ mResultsAdapter.setSyncGroups(mPlayerSyncGroups);
+
+ for (int i = 0; i < mResultsAdapter.getGroupCount(); i++) {
+ mResultsExpandableListView.expandGroup(i);
+ }
+ }
+
+ @Override
+ protected void onServiceConnected(@NonNull ISqueezeService service) {
+ super.onServiceConnected(service);
+ Log.d(TAG, "onServiceConnected: service.isConnected=" + service.isConnected());
+
+ if (!service.isConnected()) {
+ service.startConnect();
+ }
+ }
+
+ @Override
+ protected void orderPage(@NonNull ISqueezeService service, int start) {
+ // Do nothing -- the service has been tracking players from the time it
+ // initially connected to the server.
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ mResultsAdapter = new PlayerListAdapter(this);
+
+ setIgnoreVolumeChange(true);
+ }
+
+ @Override
+ protected AbsListView setupListView(AbsListView listView) {
+ mResultsExpandableListView = (ExpandableListView) listView;
+ mResultsExpandableListView.setOnGroupClickListener(new ExpandableListView.OnGroupClickListener() {
+ public boolean onGroupClick(ExpandableListView parent, View v, int groupPosition, long id) {
+ mResultsAdapter.onGroupClick(v, groupPosition);
+ return true;
+ }
+ });
+ mResultsExpandableListView.setOnChildClickListener(new ExpandableListView.OnChildClickListener() {
+ @Override
+ public boolean onChildClick(ExpandableListView parent, View v, int groupPosition,
+ int childPosition, long id) {
+ mResultsAdapter.onChildClick(v, groupPosition, childPosition);
+ return true;
+ }
+ });
+
+ mResultsExpandableListView.setOnScrollListener(new ScrollListener());
+
+ return listView;
+ }
+
+ public void onEventMainThread(HandshakeComplete event) {
+ super.onEventMainThread(event);
+ if (mResultsExpandableListView.getExpandableListAdapter() == null)
+ mResultsExpandableListView.setAdapter(mResultsAdapter);
+ updateAndExpandPlayerList();
+ }
+
+
+ public void onEventMainThread(PlayerStateChanged event) {
+ if (!mTrackingTouch) {
+ updateAndExpandPlayerList();
+ } else {
+ mUpdateWhileTracking = true;
+ }
+ }
+
+ public void setTrackingTouch(boolean trackingTouch) {
+ mTrackingTouch = trackingTouch;
+ if (!mTrackingTouch) {
+ if (mUpdateWhileTracking) {
+ mUpdateWhileTracking = false;
+ updateAndExpandPlayerList();
+ }
+ }
+ }
+
+ /**
+ * Builds the list of lists that is a sync group.
+ *
+ * @param players List of players.
+ */
+ public void updateSyncGroups(Collection players) {
+ Map connectedPlayers = new HashMap<>();
+
+ // Make a copy of the players we know about, ignoring unconnected ones.
+ for (Player player : players) {
+ if (!player.getConnected())
+ continue;
+
+ connectedPlayers.put(player.getId(), player);
+ }
+
+ mPlayerSyncGroups.clear();
+
+ // Iterate over all the connected players to build the list of master players.
+ for (Player player : connectedPlayers.values()) {
+ String playerId = player.getId();
+ String name = player.getName();
+ PlayerState playerState = player.getPlayerState();
+ String syncMaster = playerState.getSyncMaster();
+
+ Log.d(TAG, "player discovered: id=" + playerId + ", syncMaster=" + syncMaster + ", name=" + name);
+ // If a player doesn't have a sync master then it's in a group of its own.
+ if (syncMaster == null) {
+ mPlayerSyncGroups.put(playerId, player);
+ continue;
+ }
+
+ // If the master is this player then add itself and all the slaves.
+ if (playerId.equals(syncMaster)) {
+ mPlayerSyncGroups.put(playerId, player);
+ continue;
+ }
+
+ // Must be a slave. Add it under the master. This might have already
+ // happened (in the block above), but might not. For example, it's possible
+ // to have a player that's a syncslave of an player that is not connected.
+ mPlayerSyncGroups.put(syncMaster, player);
+ }
+ }
+
+ @NonNull
+ public Multimap getPlayerSyncGroups() {
+ return mPlayerSyncGroups;
+ }
+
+ @Override
+ protected void clearItemAdapter() {
+ mResultsAdapter.clear();
+ }
+
+ public abstract PlayerBaseView createPlayerView();
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/PlayerView.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/PlayerView.java
new file mode 100644
index 000000000..31dcaf8a7
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/PlayerView.java
@@ -0,0 +1,204 @@
+/*
+ * Copyright (c) 2011 Kurt Aaholst
+ *
+ * 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 uk.org.ngo.squeezer.itemlist;
+
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.PopupMenu;
+import android.widget.SeekBar;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+
+import uk.org.ngo.squeezer.R;
+import uk.org.ngo.squeezer.Util;
+import uk.org.ngo.squeezer.itemlist.dialog.DefeatDestructiveTouchToPlayDialog;
+import uk.org.ngo.squeezer.itemlist.dialog.PlayTrackAlbumDialog;
+import uk.org.ngo.squeezer.itemlist.dialog.PlayerRenameDialog;
+import uk.org.ngo.squeezer.itemlist.dialog.PlayerSyncDialog;
+import uk.org.ngo.squeezer.model.Player;
+import uk.org.ngo.squeezer.model.PlayerState;
+import uk.org.ngo.squeezer.service.ISqueezeService;
+
+public class PlayerView extends PlayerBaseView {
+
+ public PlayerView(PlayerListActivity activity) {
+ super(activity, R.layout.list_item_player);
+
+ setViewParams(VIEW_PARAM_ICON | VIEW_PARAM_TWO_LINE | VIEW_PARAM_CONTEXT_BUTTON);
+ setLoadingViewParams(VIEW_PARAM_ICON | VIEW_PARAM_TWO_LINE);
+ }
+
+ @Override
+ public ViewHolder createViewHolder(View view) {
+ return new PlayerViewHolder(view);
+ }
+
+ @Override
+ public void bindView(View view, Player item) {
+ PlayerState playerState = item.getPlayerState();
+ PlayerViewHolder viewHolder = (PlayerViewHolder) view.getTag();
+
+ super.bindView(view, item);
+ viewHolder.icon.setImageResource(getModelIcon(item.getModel()));
+
+ if (viewHolder.volumeBar == null) {
+ viewHolder.volumeBar = view.findViewById(R.id.volume_slider);
+ viewHolder.volumeBar.setOnSeekBarChangeListener(new VolumeSeekBarChangeListener(item, viewHolder.volumeValue));
+ }
+
+ viewHolder.volumeBar.setVisibility(View.VISIBLE);
+
+ if (playerState.isPoweredOn()) {
+ viewHolder.text1.setAlpha(1.0f);
+ } else {
+ viewHolder.text1.setAlpha(0.25f);
+ }
+
+ viewHolder.volumeBar.setProgress(playerState.getCurrentVolume());
+
+ viewHolder.text2.setVisibility(playerState.getSleepDuration() > 0 ? View.VISIBLE : View.INVISIBLE);
+ if (playerState.getSleepDuration() > 0) {
+ viewHolder.text2.setText(activity.getString(R.string.SLEEPING_IN)
+ + " " + Util.formatElapsedTime(item.getSleepingIn()));
+ }
+ }
+
+ @Override
+ public void showContextMenu(ViewHolder viewHolder, final Player item) {
+ PopupMenu popup = new PopupMenu(getActivity(), viewHolder.contextMenuButtonHolder);
+ popup.inflate(R.menu.playercontextmenu);
+
+ Menu menu = popup.getMenu();
+ PlayerViewLogic.inflatePlayerActions(activity, popup.getMenuInflater(), menu);
+
+ PlayerState playerState = item.getPlayerState();
+ menu.findItem(R.id.cancel_sleep).setVisible(playerState.getSleepDuration() != 0);
+
+ menu.findItem(R.id.end_of_song).setVisible(playerState.isPlaying());
+
+ menu.findItem(R.id.toggle_power).setTitle(playerState.isPoweredOn() ? R.string.menu_item_power_off : R.string.menu_item_power_on);
+
+ // Enable player sync menu options if there's more than one player.
+ menu.findItem(R.id.player_sync).setVisible(activity.mResultsAdapter.mPlayerCount > 1);
+
+ menu.findItem(R.id.play_track_album).setVisible(playerState.prefs.containsKey(Player.Pref.PLAY_TRACK_ALBUM));
+
+ menu.findItem(R.id.defeat_destructive_ttp).setVisible(playerState.prefs.containsKey(Player.Pref.DEFEAT_DESTRUCTIVE_TTP));
+
+ popup.setOnMenuItemClickListener(menuItem -> doItemContext(menuItem, item));
+
+ activity.mResultsAdapter.mPlayersChanged = false;
+ popup.show();
+ }
+
+ private boolean doItemContext(MenuItem menuItem, Player selectedItem) {
+ if (activity.mResultsAdapter.mPlayersChanged) {
+ Toast.makeText(activity, activity.getText(R.string.player_list_changed),
+ Toast.LENGTH_LONG).show();
+ return true;
+ }
+
+ activity.setCurrentPlayer(selectedItem);
+ ISqueezeService service = activity.getService();
+ if (service == null) {
+ return true;
+ }
+
+ if (PlayerViewLogic.doPlayerAction(service, menuItem, selectedItem)) {
+ return true;
+ }
+
+ switch (menuItem.getItemId()) {
+ case R.id.rename:
+ new PlayerRenameDialog().show(activity.getSupportFragmentManager(),
+ PlayerRenameDialog.class.getName());
+ return true;
+ case R.id.player_sync:
+ new PlayerSyncDialog().show(activity.getSupportFragmentManager(),
+ PlayerSyncDialog.class.getName());
+ return true;
+ case R.id.play_track_album:
+ PlayTrackAlbumDialog.show(activity);
+ return true;
+ case R.id.defeat_destructive_ttp:
+ DefeatDestructiveTouchToPlayDialog.show(activity);
+ return true;
+ }
+
+ return false;
+ }
+
+ private class VolumeSeekBarChangeListener implements SeekBar.OnSeekBarChangeListener {
+ private final Player player;
+ private final TextView valueView;
+
+ public VolumeSeekBarChangeListener(Player player, TextView valueView) {
+ this.player = player;
+ this.valueView = valueView;
+ }
+
+ @Override
+ public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
+ if (fromUser) {
+ ISqueezeService service = activity.getService();
+ if (service == null) {
+ return;
+ }
+ service.adjustVolumeTo(player, progress);
+ }
+ }
+
+ @Override
+ public void onStartTrackingTouch(SeekBar seekBar) {
+ activity.setTrackingTouch(true);
+ }
+
+ @Override
+ public void onStopTrackingTouch(SeekBar seekBar) {
+ activity.setTrackingTouch(false);
+ }
+ }
+
+ private class PowerButtonClickListener implements View.OnClickListener {
+ private final Player player;
+
+ private PowerButtonClickListener(Player player) {
+ this.player = player;
+ }
+
+ @Override
+ public void onClick(View v) {
+ ISqueezeService service = activity.getService();
+ if (service == null) {
+ return;
+ }
+ service.togglePower(player);
+ }
+ }
+
+ private static class PlayerViewHolder extends ViewHolder {
+ SeekBar volumeBar;
+ TextView volumeValue;
+
+ public PlayerViewHolder(@NonNull View view) {
+ super(view);
+ }
+ }
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/PlayerViewLogic.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/PlayerViewLogic.java
new file mode 100644
index 000000000..52808bf85
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/PlayerViewLogic.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright (c) 2020 Kurt Aaholst
+ *
+ * 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 uk.org.ngo.squeezer.itemlist;
+
+import android.content.Context;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+
+import uk.org.ngo.squeezer.R;
+import uk.org.ngo.squeezer.model.Player;
+import uk.org.ngo.squeezer.model.PlayerState;
+import uk.org.ngo.squeezer.service.ISqueezeService;
+import uk.org.ngo.squeezer.service.event.SongTimeChanged;
+
+public class PlayerViewLogic {
+
+ /**
+ * Inflate common player actions onto the supplied menu
+ */
+ public static void inflatePlayerActions(Context context, MenuInflater inflater, Menu menu) {
+ inflater.inflate(R.menu.playermenu, menu);
+
+ String xMinutes = context.getString(R.string.X_MINUTES);
+ menu.findItem(R.id.in_15_minutes).setTitle(String.format(xMinutes, "15"));
+ menu.findItem(R.id.in_30_minutes).setTitle(String.format(xMinutes, "30"));
+ menu.findItem(R.id.in_45_minutes).setTitle(String.format(xMinutes, "45"));
+ menu.findItem(R.id.in_60_minutes).setTitle(String.format(xMinutes, "60"));
+ menu.findItem(R.id.in_90_minutes).setTitle(String.format(xMinutes, "90"));
+ }
+
+ /**
+ * If menu item is a known player action, perform it and return true.
+ */
+ public static boolean doPlayerAction(ISqueezeService service, MenuItem menuItem, Player selectedItem) {
+ switch (menuItem.getItemId()) {
+ case R.id.sleep:
+ // This is the start of a context menu.
+ // Just return, as we have set the current player.
+ return true;
+ case R.id.end_of_song: {
+ PlayerState playerState = selectedItem.getPlayerState();
+ if (playerState.isPlaying()) {
+ SongTimeChanged trackElapsed = selectedItem.getTrackElapsed();
+ int sleep = trackElapsed.duration - trackElapsed.currentPosition + 1;
+ if (sleep >= 0)
+ service.sleep(selectedItem, sleep);
+ }
+ return true;
+ }
+ case R.id.in_15_minutes:
+ service.sleep(selectedItem, 15*60);
+ return true;
+ case R.id.in_30_minutes:
+ service.sleep(selectedItem, 30*60);
+ return true;
+ case R.id.in_45_minutes:
+ service.sleep(selectedItem, 45*60);
+ return true;
+ case R.id.in_60_minutes:
+ service.sleep(selectedItem, 60*60);
+ return true;
+ case R.id.in_90_minutes:
+ service.sleep(selectedItem, 90*60);
+ return true;
+ case R.id.cancel_sleep:
+ service.sleep(selectedItem, 0);
+ return true;
+ case R.id.toggle_power:
+ service.togglePower(selectedItem);
+ return true;
+ }
+
+
+ return false;
+ }
+
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/AlarmSettingsDialog.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/AlarmSettingsDialog.java
new file mode 100644
index 000000000..f2e6495e9
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/AlarmSettingsDialog.java
@@ -0,0 +1,176 @@
+/*
+ * Copyright (c) 2015 Google Inc. 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 uk.org.ngo.squeezer.itemlist.dialog;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.app.Dialog;
+import android.content.DialogInterface;
+import android.os.Bundle;
+import androidx.annotation.NonNull;
+import androidx.fragment.app.DialogFragment;
+import android.view.View;
+import android.widget.CompoundButton;
+import android.widget.SeekBar;
+import android.widget.TextView;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import uk.org.ngo.squeezer.R;
+import uk.org.ngo.squeezer.model.Player;
+
+/**
+ * A dialog with controls to manage a player's default alarm preferences (volume, snooze duration,
+ * etc).
+ *
+ * Activities that host this dialog must implement {@link AlarmSettingsDialog.HostActivity}
+ * to provide information about the preferences, and to save the new values when the user
+ * selects the dialog's positive action button.
+ */
+public class AlarmSettingsDialog extends DialogFragment {
+ private HostActivity mHostActivity;
+
+ /** Activities that host this dialog must implement this interface. */
+ public interface HostActivity {
+ /**
+ * @return The current player.
+ */
+ @NonNull
+ Player getPlayer();
+
+ /**
+ * @param playerPref the name of the preference to get
+ * @param def the default value to return if the preference does not exist
+ * @return The value of the PlayerPref identified by playerPref
+ */
+ @NonNull
+ String getPlayerPref(@NonNull @Player.Pref.Name String playerPref, @NonNull String def);
+
+ /**
+ * Called when the user selects the dialog's positive button.
+ *
+ * @param volume The user's chosen volume
+ * @param snooze The user's chosen snooze duration, in seconds
+ * @param timeout The user's chosen timeout duration, in seconds
+ * @param fade Whether alarms should fade up
+ */
+ void onPositiveClick(int volume, int snooze, int timeout, boolean fade);
+ }
+
+ @Override
+ public void onAttach(Activity activity) {
+ super.onAttach(activity);
+
+ // Verify that the host activity implements the callback interface
+ try {
+ // Instantiate the NoticeDialogListener so we can send events to the host
+ mHostActivity = (HostActivity) activity;
+ } catch (ClassCastException e) {
+ // The activity doesn't implement the interface, throw exception
+ throw new ClassCastException(activity.toString()
+ + " must implement HostActivity");
+ }
+ }
+
+ @NonNull
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ @SuppressLint({"InflateParams"})
+ final View view = getActivity().getLayoutInflater().inflate(R.layout.alarm_settings_dialog, null);
+
+ final TextView alarmVolumeHint = view.findViewById(R.id.alarm_volume_hint);
+ final TextView alarmSnoozeHint = view.findViewById(R.id.alarm_snooze_hint);
+ final TextView alarmTimeoutHint = view.findViewById(R.id.alarm_timeout_hint);
+ final TextView alarmFadeHint = view.findViewById(R.id.alarm_fade_hint);
+
+ final SeekBar alarmVolume = view.findViewById(R.id.alarm_volume_seekbar);
+ final SeekBar alarmSnooze = view.findViewById(R.id.alarm_snooze_seekbar);
+ final SeekBar alarmTimeout = view.findViewById(R.id.alarm_timeout_seekbar);
+
+ final CompoundButton alarmFadeToggle = view.findViewById(R.id.alarm_fade);
+
+ alarmVolume.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
+ @Override
+ public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
+ alarmVolumeHint.setText(String.format("%d%%", progress));
+ }
+
+ @Override
+ public void onStartTrackingTouch(SeekBar seekBar) { }
+
+ @Override
+ public void onStopTrackingTouch(SeekBar seekBar) { }
+ });
+
+ alarmSnooze.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
+ @Override
+ public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
+ alarmSnoozeHint.setText(getResources().getQuantityString(R.plurals.alarm_snooze_hint_text,
+ progress, progress));
+ }
+
+ @Override
+ public void onStartTrackingTouch(SeekBar seekBar) { }
+
+ @Override
+ public void onStopTrackingTouch(SeekBar seekBar) { }
+ });
+
+ alarmTimeout.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
+ @Override
+ public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
+ if (progress == 0) {
+ alarmTimeoutHint.setText(R.string.alarm_timeout_hint_text_zero);
+ } else {
+ alarmTimeoutHint.setText(getResources().getQuantityString(R.plurals.alarm_timeout_hint_text,
+ progress, progress));
+ }
+ }
+
+ @Override
+ public void onStartTrackingTouch(SeekBar seekBar) { }
+
+ @Override
+ public void onStopTrackingTouch(SeekBar seekBar) { }
+ });
+
+ alarmFadeToggle.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
+ @Override
+ public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
+ alarmFadeHint.setText(isChecked ? R.string.alarm_fade_on_text : R.string.alarm_fade_off_text);
+ }
+ });
+
+ alarmVolume.setProgress(Integer.valueOf(mHostActivity.getPlayerPref(Player.Pref.ALARM_DEFAULT_VOLUME, "50")));
+ alarmSnooze.setProgress(Integer.valueOf(mHostActivity.getPlayerPref(Player.Pref.ALARM_SNOOZE_SECONDS, "600")) / 60);
+ alarmTimeout.setProgress(Integer.valueOf(mHostActivity.getPlayerPref(Player.Pref.ALARM_TIMEOUT_SECONDS, "300")) / 60);
+ alarmFadeToggle.setChecked("1".equals(mHostActivity.getPlayerPref(Player.Pref.ALARM_FADE_SECONDS, "0")));
+
+ MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(getActivity());
+ builder.setView(view);
+ builder.setTitle(getResources().getString(R.string.alarms_settings_dialog_title, mHostActivity.getPlayer().getName()));
+ builder.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ mHostActivity.onPositiveClick(alarmVolume.getProgress(), alarmSnooze.getProgress() * 60,
+ alarmTimeout.getProgress() * 60, alarmFadeToggle.isChecked());
+ }
+ });
+ builder.setNegativeButton(android.R.string.cancel, null);
+ return builder.create();
+ }
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/ArtworkDialog.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/ArtworkDialog.java
new file mode 100644
index 000000000..924cd92ed
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/ArtworkDialog.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright (c) 2019 Kurt Aaholst. 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 uk.org.ngo.squeezer.itemlist.dialog;
+
+import android.app.Dialog;
+import android.graphics.Rect;
+import android.net.Uri;
+import android.os.Bundle;
+import androidx.annotation.NonNull;
+import androidx.fragment.app.DialogFragment;
+import android.view.Window;
+import android.widget.ImageView;
+
+import java.util.List;
+import java.util.Map;
+
+import uk.org.ngo.squeezer.R;
+import uk.org.ngo.squeezer.Util;
+import uk.org.ngo.squeezer.model.Action;
+import uk.org.ngo.squeezer.framework.BaseActivity;
+import uk.org.ngo.squeezer.itemlist.IServiceItemListCallback;
+import uk.org.ngo.squeezer.model.JiveItem;
+import uk.org.ngo.squeezer.service.ISqueezeService;
+import uk.org.ngo.squeezer.util.ImageFetcher;
+
+public class ArtworkDialog extends DialogFragment implements IServiceItemListCallback {
+ private static final String TAG = DialogFragment.class.getSimpleName();
+ private ImageView artwork;
+
+ @NonNull
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ BaseActivity activity = (BaseActivity)getActivity();
+ Action action = getArguments().getParcelable(Action.class.getName());
+
+ Dialog dialog = new Dialog(getContext());
+ dialog.setContentView(R.layout.show_artwork);
+ artwork = dialog.findViewById(R.id.artwork);
+
+ Rect rect = new Rect();
+ Window window = dialog.getWindow();
+ window.getDecorView().getWindowVisibleDisplayFrame(rect);
+ int size = Math.min(rect.width(), rect.height());
+ window.setLayout(size, size);
+
+ // FIXME Image wont get fetched (and thus not displayed) after orientation change
+ if (activity.getService()!= null) {
+ activity.getService().pluginItems(action, this);
+ }
+
+ return dialog;
+ }
+
+ @Override
+ public void onItemsReceived(int count, int start, Map parameters, List items, Class dataType) {
+ Uri artworkId = Util.getImageUrl(parameters, parameters.containsKey("artworkId") ? "artworkId" : "artworkUrl");
+ ImageFetcher.getInstance(getContext()).loadImage(artworkId, artwork);
+ }
+
+ @Override
+ public Object getClient() {
+ return getActivity();
+ }
+
+ /**
+ * Create a dialog to show artwork.
+ *
+ * We call {@link ISqueezeService#pluginItems(Action, IServiceItemListCallback)} with the
+ * supplied action to asynchronously order an artwork id or URL. When the response
+ * arrives we load the artwork into the dialog.
+ *
+ * See Slim/Control/Queries.pm in the slimserver code
+ */
+ public static ArtworkDialog show(BaseActivity activity, Action action) {
+ // Create and show the dialog
+ ArtworkDialog dialog = new ArtworkDialog();
+
+ Bundle args = new Bundle();
+ args.putParcelable(Action.class.getName(), action);
+ dialog.setArguments(args);
+
+ dialog.show(activity.getSupportFragmentManager(), TAG);
+ return dialog;
+ }
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/ArtworkListLayout.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/ArtworkListLayout.java
new file mode 100644
index 000000000..6a660c97c
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/ArtworkListLayout.java
@@ -0,0 +1,31 @@
+package uk.org.ngo.squeezer.itemlist.dialog;
+
+import android.content.Context;
+
+import androidx.annotation.StringRes;
+
+import uk.org.ngo.squeezer.R;
+import uk.org.ngo.squeezer.framework.EnumWithText;
+
+/**
+ * Supported list layouts.
+ */
+public enum ArtworkListLayout implements EnumWithText {
+ grid(R.string.SWITCH_TO_GALLERY),
+ list(R.string.SWITCH_TO_EXTENDED_LIST);
+
+ /**
+ * The text to use for this layout
+ */
+ @StringRes
+ private final int stringResource;
+
+ @Override
+ public String getText(Context context) {
+ return context.getString(stringResource);
+ }
+
+ ArtworkListLayout(@StringRes int serverString) {
+ this.stringResource = serverString;
+ }
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/BaseChoicesDialog.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/BaseChoicesDialog.java
new file mode 100644
index 000000000..329dc323c
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/BaseChoicesDialog.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright (c) 2020 Kurt Aaholst. 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 uk.org.ngo.squeezer.itemlist.dialog;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.app.Dialog;
+import android.view.View;
+import android.widget.RadioButton;
+import android.widget.RadioGroup;
+import android.widget.TextView;
+
+import androidx.fragment.app.DialogFragment;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import uk.org.ngo.squeezer.R;
+
+public abstract class BaseChoicesDialog extends DialogFragment {
+
+ Dialog createDialog(String title, String message, int selectedIndex, String[] choiceStrings) {
+ final Activity activity = getActivity();
+
+ @SuppressLint({"InflateParams"})
+ View content = activity.getLayoutInflater().inflate(R.layout.choices_layout, null);
+
+
+ if (message != null) {
+ content.findViewById(R.id.message).setVisibility(View.VISIBLE);
+ content.findViewById(R.id.message).setText(message);
+ }
+
+ RadioGroup radioGroup = content.findViewById(R.id.choices);
+ for (int i = 0; i < choiceStrings.length; i++) {
+ RadioButton radioButton = new RadioButton(activity);
+ radioButton.setText(choiceStrings[i]);
+ radioButton.setId(i);
+ radioGroup.addView(radioButton);
+ }
+ radioGroup.check(selectedIndex);
+
+ radioGroup.setOnCheckedChangeListener(new RadioGroup.OnCheckedChangeListener() {
+ @Override
+ public void onCheckedChanged(RadioGroup group, int checkedId) {
+ onSelectOption(checkedId);
+ dismiss();
+ }
+ });
+
+ return new MaterialAlertDialogBuilder(getContext())
+ .setTitle(title)
+ .setView(content)
+ .create();
+ }
+
+ protected abstract void onSelectOption(int checkedId);
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/BaseEditTextDialog.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/BaseEditTextDialog.java
new file mode 100644
index 000000000..440c82686
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/BaseEditTextDialog.java
@@ -0,0 +1,61 @@
+package uk.org.ngo.squeezer.itemlist.dialog;
+
+import android.annotation.SuppressLint;
+import android.app.Dialog;
+import android.content.DialogInterface;
+import android.os.Bundle;
+import androidx.annotation.NonNull;
+import androidx.fragment.app.DialogFragment;
+import android.view.KeyEvent;
+import android.view.View;
+import android.view.View.OnKeyListener;
+import android.widget.EditText;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.google.android.material.textfield.TextInputLayout;
+
+import uk.org.ngo.squeezer.R;
+
+public abstract class BaseEditTextDialog extends DialogFragment {
+
+ protected TextInputLayout editTextLayout;
+ protected EditText editText;
+
+ abstract protected boolean commit(String string);
+
+ @NonNull
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(getActivity());
+
+ @SuppressLint({"InflateParams"})
+ View form = getActivity().getLayoutInflater().inflate(R.layout.edittext_dialog, null);
+ builder.setView(form);
+ editTextLayout = form.findViewById(R.id.edittext_til);
+ editText = form.findViewById(R.id.edittext);
+
+ editText.setText("");
+ editText.setOnKeyListener(new OnKeyListener() {
+ @Override
+ public boolean onKey(View v, int keyCode, KeyEvent event) {
+ if ((event.getAction() == KeyEvent.ACTION_DOWN) && (keyCode
+ == KeyEvent.KEYCODE_ENTER)) {
+ if (commit(editText.getText().toString())) {
+ dismiss();
+ }
+ return true;
+ }
+ return false;
+ }
+ });
+ builder.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ commit(editText.getText().toString());
+ }
+ });
+
+ return builder.create();
+ }
+
+}
diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/ChoicesDialog.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/ChoicesDialog.java
new file mode 100644
index 000000000..5531d1b97
--- /dev/null
+++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/ChoicesDialog.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright (c) 2019 Kurt Aaholst. 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 uk.org.ngo.squeezer.itemlist.dialog;
+
+import android.app.Dialog;
+import android.os.Bundle;
+
+import androidx.annotation.NonNull;
+
+import uk.org.ngo.squeezer.framework.BaseActivity;
+import uk.org.ngo.squeezer.model.JiveItem;
+
+public class ChoicesDialog extends BaseChoicesDialog {
+
+ private BaseActivity activity;
+ private JiveItem item;
+ private int alreadyPopped;
+
+ @NonNull
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ activity = (BaseActivity)getActivity();
+ item = getArguments().getParcelable(JiveItem.class.getName());
+ alreadyPopped = getArguments().getInt("alreadyPopped", 0);
+ return createDialog(item.getName(), null, item.selectedIndex-1, item.choiceStrings);
+ }
+
+ @Override
+ protected void onSelectOption(int checkedId) {
+ activity.action(item.goAction.choices[checkedId], alreadyPopped);
+ }
+
+ /**
+ * Create a dialog to select from choices
+ *