aboutsummaryrefslogtreecommitdiff
path: root/subsonic-android/src
diff options
context:
space:
mode:
Diffstat (limited to 'subsonic-android/src')
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/DownloadActivity.java874
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/EqualizerActivity.java181
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/HelpActivity.java117
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/LyricsActivity.java72
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/MainActivity.java258
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/PlayVideoActivity.java147
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/QueryReceiverActivity.java56
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/SearchActivity.java368
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/SelectAlbumActivity.java568
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/SelectArtistActivity.java228
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/SelectPlaylistActivity.java141
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/SettingsActivity.java297
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/SubsonicTabActivity.java383
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/VoiceQueryReceiverActivity.java59
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/audiofx/EqualizerController.java138
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/audiofx/VisualizerController.java90
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/Artist.java60
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/Indexes.java50
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/JukeboxStatus.java63
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/Lyrics.java55
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/MusicDirectory.java259
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/MusicFolder.java46
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/PlayerState.java34
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/Playlist.java56
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/RepeatMode.java28
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/SearchCritera.java55
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/SearchResult.java51
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/ServerInfo.java46
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/Version.java142
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/provider/SearchSuggestionProvider.java36
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/provider/SubsonicAppWidgetProvider.java238
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/receiver/BluetoothIntentReceiver.java53
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/receiver/MediaButtonIntentReceiver.java50
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/service/CachedMusicService.java237
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/service/DownloadFile.java323
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/service/DownloadService.java112
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/service/DownloadServiceImpl.java930
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/service/DownloadServiceLifecycleSupport.java266
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/service/JukeboxService.java356
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/service/MediaStoreService.java109
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/service/MusicService.java91
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/service/MusicServiceFactory.java36
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/service/OfflineException.java32
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/service/OfflineMusicService.java244
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/service/RESTMusicService.java768
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/service/Scrobbler.java52
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/service/ServerTooOldException.java51
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/AbstractParser.java138
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/AlbumListParser.java62
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/ErrorParser.java49
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/IndexesParser.java104
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/JukeboxStatusParser.java62
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/LicenseParser.java62
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/LyricsParser.java65
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/MusicDirectoryEntryParser.java59
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/MusicDirectoryParser.java71
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/MusicFoldersParser.java69
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/PlaylistParser.java62
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/PlaylistsParser.java67
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/RandomSongsParser.java62
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/SearchResult2Parser.java75
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/SearchResultParser.java67
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/SubsonicRESTException.java19
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/VersionParser.java47
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/service/ssl/SSLSocketFactory.java497
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/service/ssl/TrustManagerDecorator.java65
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/service/ssl/TrustSelfSignedStrategy.java44
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/service/ssl/TrustStrategy.java57
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/util/AlbumView.java55
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/util/ArtistAdapter.java78
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/util/BackgroundTask.java96
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/util/CacheCleaner.java171
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/util/CancellableTask.java87
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/util/Constants.java91
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/util/EntryAdapter.java71
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/util/ErrorDialog.java61
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/util/FileUtil.java301
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/util/HorizontalSlider.java141
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/util/ImageLoader.java252
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/util/LRUCache.java102
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/util/MergeAdapter.java290
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/util/ModalBackgroundTask.java139
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/util/MyViewFlipper.java53
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/util/Pair.java54
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/util/PlaylistAdapter.java99
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/util/ProgressListener.java27
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/util/SackOfViewsAdapter.java181
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/util/ShufflePlayBuffer.java109
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/util/SilentBackgroundTask.java67
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/util/SimpleServiceBinder.java37
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/util/SongView.java178
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/util/TabActivityBackgroundTask.java67
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/util/TimeLimitedCache.java55
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/util/Util.java829
-rw-r--r--subsonic-android/src/net/sourceforge/subsonic/androidapp/view/VisualizerView.java132
95 files changed, 14532 insertions, 0 deletions
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/DownloadActivity.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/DownloadActivity.java
new file mode 100644
index 00000000..68144481
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/DownloadActivity.java
@@ -0,0 +1,874 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.activity;
+
+import java.text.DateFormat;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.graphics.Color;
+import android.graphics.Typeface;
+import android.os.Bundle;
+import android.os.Handler;
+import android.view.ContextMenu;
+import android.view.Display;
+import android.view.GestureDetector;
+import android.view.GestureDetector.OnGestureListener;
+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.view.WindowManager;
+import android.view.animation.AnimationUtils;
+import android.widget.AdapterView;
+import android.widget.ArrayAdapter;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.ImageButton;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.ListView;
+import android.widget.TextView;
+import android.widget.ViewFlipper;
+import net.sourceforge.subsonic.androidapp.R;
+import net.sourceforge.subsonic.androidapp.domain.MusicDirectory;
+import net.sourceforge.subsonic.androidapp.domain.PlayerState;
+import net.sourceforge.subsonic.androidapp.domain.RepeatMode;
+import net.sourceforge.subsonic.androidapp.service.DownloadFile;
+import net.sourceforge.subsonic.androidapp.service.DownloadService;
+import net.sourceforge.subsonic.androidapp.service.MusicService;
+import net.sourceforge.subsonic.androidapp.service.MusicServiceFactory;
+import net.sourceforge.subsonic.androidapp.util.Constants;
+import net.sourceforge.subsonic.androidapp.util.HorizontalSlider;
+import net.sourceforge.subsonic.androidapp.util.SilentBackgroundTask;
+import net.sourceforge.subsonic.androidapp.util.SongView;
+import net.sourceforge.subsonic.androidapp.util.Util;
+import net.sourceforge.subsonic.androidapp.view.VisualizerView;
+
+import static net.sourceforge.subsonic.androidapp.domain.PlayerState.*;
+
+public class DownloadActivity extends SubsonicTabActivity implements OnGestureListener {
+
+ private static final int DIALOG_SAVE_PLAYLIST = 100;
+ private static final int PERCENTAGE_OF_SCREEN_FOR_SWIPE = 5;
+ private static final int COLOR_BUTTON_ENABLED = Color.rgb(129, 201, 54);
+ private static final int COLOR_BUTTON_DISABLED = Color.rgb(164, 166, 158);
+
+ private ViewFlipper playlistFlipper;
+ private ViewFlipper buttonBarFlipper;
+ private TextView emptyTextView;
+ private TextView songTitleTextView;
+ private TextView albumTextView;
+ private TextView artistTextView;
+ private ImageView albumArtImageView;
+ private ListView playlistView;
+ private TextView positionTextView;
+ private TextView durationTextView;
+ private TextView statusTextView;
+ private HorizontalSlider progressBar;
+ private View previousButton;
+ private View nextButton;
+ private View pauseButton;
+ private View stopButton;
+ private View startButton;
+ private View shuffleButton;
+ private ImageButton repeatButton;
+ private Button equalizerButton;
+ private Button visualizerButton;
+ private Button jukeboxButton;
+ private View toggleListButton;
+ private ScheduledExecutorService executorService;
+ private DownloadFile currentPlaying;
+ private long currentRevision;
+ private EditText playlistNameView;
+ private GestureDetector gestureScanner;
+ private int swipeDistance;
+ private int swipeVelocity;
+ private VisualizerView visualizerView;
+
+ /**
+ * Called when the activity is first created.
+ */
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.download);
+
+ WindowManager w = getWindowManager();
+ Display d = w.getDefaultDisplay();
+ swipeDistance = (d.getWidth() + d.getHeight()) * PERCENTAGE_OF_SCREEN_FOR_SWIPE / 100;
+ swipeVelocity = (d.getWidth() + d.getHeight()) * PERCENTAGE_OF_SCREEN_FOR_SWIPE / 100;
+ gestureScanner = new GestureDetector(this);
+
+ playlistFlipper = (ViewFlipper) findViewById(R.id.download_playlist_flipper);
+ buttonBarFlipper = (ViewFlipper) findViewById(R.id.download_button_bar_flipper);
+ emptyTextView = (TextView) findViewById(R.id.download_empty);
+ songTitleTextView = (TextView) findViewById(R.id.download_song_title);
+ albumTextView = (TextView) findViewById(R.id.download_album);
+ artistTextView = (TextView) findViewById(R.id.download_artist);
+ albumArtImageView = (ImageView) findViewById(R.id.download_album_art_image);
+ positionTextView = (TextView) findViewById(R.id.download_position);
+ durationTextView = (TextView) findViewById(R.id.download_duration);
+ statusTextView = (TextView) findViewById(R.id.download_status);
+ progressBar = (HorizontalSlider) findViewById(R.id.download_progress_bar);
+ playlistView = (ListView) findViewById(R.id.download_list);
+ previousButton = findViewById(R.id.download_previous);
+ nextButton = findViewById(R.id.download_next);
+ pauseButton = findViewById(R.id.download_pause);
+ stopButton = findViewById(R.id.download_stop);
+ startButton = findViewById(R.id.download_start);
+ shuffleButton = findViewById(R.id.download_shuffle);
+ repeatButton = (ImageButton) findViewById(R.id.download_repeat);
+ equalizerButton = (Button) findViewById(R.id.download_equalizer);
+ visualizerButton = (Button) findViewById(R.id.download_visualizer);
+ jukeboxButton = (Button) findViewById(R.id.download_jukebox);
+ LinearLayout visualizerViewLayout = (LinearLayout) findViewById(R.id.download_visualizer_view_layout);
+
+ toggleListButton = findViewById(R.id.download_toggle_list);
+
+ View.OnTouchListener touchListener = new View.OnTouchListener() {
+ @Override
+ public boolean onTouch(View v, MotionEvent me) {
+ return gestureScanner.onTouchEvent(me);
+ }
+ };
+ previousButton.setOnTouchListener(touchListener);
+ nextButton.setOnTouchListener(touchListener);
+ pauseButton.setOnTouchListener(touchListener);
+ stopButton.setOnTouchListener(touchListener);
+ startButton.setOnTouchListener(touchListener);
+ equalizerButton.setOnTouchListener(touchListener);
+ visualizerButton.setOnTouchListener(touchListener);
+ jukeboxButton.setOnTouchListener(touchListener);
+ buttonBarFlipper.setOnTouchListener(touchListener);
+ emptyTextView.setOnTouchListener(touchListener);
+ albumArtImageView.setOnTouchListener(touchListener);
+
+ albumArtImageView.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ toggleFullscreenAlbumArt();
+ }
+ });
+
+ previousButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ warnIfNetworkOrStorageUnavailable();
+ getDownloadService().previous();
+ onCurrentChanged();
+ onProgressChanged();
+ }
+ });
+
+ nextButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ warnIfNetworkOrStorageUnavailable();
+ if (getDownloadService().getCurrentPlayingIndex() < getDownloadService().size() - 1) {
+ getDownloadService().next();
+ onCurrentChanged();
+ onProgressChanged();
+ }
+ }
+ });
+
+ pauseButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ getDownloadService().pause();
+ onCurrentChanged();
+ onProgressChanged();
+ }
+ });
+
+ stopButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ getDownloadService().reset();
+ onCurrentChanged();
+ onProgressChanged();
+ }
+ });
+
+ startButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ warnIfNetworkOrStorageUnavailable();
+ start();
+ onCurrentChanged();
+ onProgressChanged();
+ }
+ });
+
+ shuffleButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ getDownloadService().shuffle();
+ Util.toast(DownloadActivity.this, R.string.download_menu_shuffle_notification);
+ }
+ });
+
+ repeatButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ RepeatMode repeatMode = getDownloadService().getRepeatMode().next();
+ getDownloadService().setRepeatMode(repeatMode);
+ onDownloadListChanged();
+ switch (repeatMode) {
+ case OFF:
+ Util.toast(DownloadActivity.this, R.string.download_repeat_off);
+ break;
+ case ALL:
+ Util.toast(DownloadActivity.this, R.string.download_repeat_all);
+ break;
+ case SINGLE:
+ Util.toast(DownloadActivity.this, R.string.download_repeat_single);
+ break;
+ default:
+ break;
+ }
+ }
+ });
+
+ equalizerButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ startActivity(new Intent(DownloadActivity.this, EqualizerActivity.class));
+ }
+ });
+
+ visualizerButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ boolean active = !visualizerView.isActive();
+ visualizerView.setActive(active);
+ getDownloadService().setShowVisualization(visualizerView.isActive());
+ updateButtons();
+ Util.toast(DownloadActivity.this, active ? R.string.download_visualizer_on : R.string.download_visualizer_off);
+ }
+ });
+
+ jukeboxButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ boolean jukeboxEnabled = !getDownloadService().isJukeboxEnabled();
+ getDownloadService().setJukeboxEnabled(jukeboxEnabled);
+ updateButtons();
+ Util.toast(DownloadActivity.this, jukeboxEnabled ? R.string.download_jukebox_on : R.string.download_jukebox_off, false);
+ }
+ });
+
+ toggleListButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ toggleFullscreenAlbumArt();
+ }
+ });
+
+ progressBar.setOnSliderChangeListener(new HorizontalSlider.OnSliderChangeListener() {
+ @Override
+ public void onSliderChanged(View view, int position, boolean inProgress) {
+ Util.toast(DownloadActivity.this, Util.formatDuration(position / 1000), true);
+ if (!inProgress) {
+ getDownloadService().seekTo(position);
+ onProgressChanged();
+ }
+ }
+ });
+ playlistView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ warnIfNetworkOrStorageUnavailable();
+ getDownloadService().play(position);
+ onCurrentChanged();
+ onProgressChanged();
+ }
+ });
+
+ registerForContextMenu(playlistView);
+
+ DownloadService downloadService = getDownloadService();
+ if (downloadService != null && getIntent().getBooleanExtra(Constants.INTENT_EXTRA_NAME_SHUFFLE, false)) {
+ warnIfNetworkOrStorageUnavailable();
+ downloadService.setShufflePlayEnabled(true);
+ }
+
+ boolean visualizerAvailable = downloadService != null && downloadService.getVisualizerController() != null;
+ boolean equalizerAvailable = downloadService != null && downloadService.getEqualizerController() != null;
+
+ if (!equalizerAvailable) {
+ equalizerButton.setVisibility(View.GONE);
+ }
+ if (!visualizerAvailable) {
+ visualizerButton.setVisibility(View.GONE);
+ } else {
+ visualizerView = new VisualizerView(this);
+ visualizerViewLayout.addView(visualizerView, new LinearLayout.LayoutParams(LinearLayout.LayoutParams.FILL_PARENT, LinearLayout.LayoutParams.FILL_PARENT));
+
+ visualizerView.setOnTouchListener(new View.OnTouchListener() {
+ @Override
+ public boolean onTouch(View view, MotionEvent motionEvent) {
+ visualizerView.setActive(!visualizerView.isActive());
+ getDownloadService().setShowVisualization(visualizerView.isActive());
+ updateButtons();
+ return true;
+ }
+ });
+ }
+
+ // TODO: Extract to utility method and cache.
+ Typeface typeface = Typeface.createFromAsset(getAssets(), "fonts/Storopia.ttf");
+ equalizerButton.setTypeface(typeface);
+ visualizerButton.setTypeface(typeface);
+ jukeboxButton.setTypeface(typeface);
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+
+ final Handler handler = new Handler();
+ Runnable runnable = new Runnable() {
+ @Override
+ public void run() {
+ handler.post(new Runnable() {
+ @Override
+ public void run() {
+ update();
+ }
+ });
+ }
+ };
+
+ executorService = Executors.newSingleThreadScheduledExecutor();
+ executorService.scheduleWithFixedDelay(runnable, 0L, 1000L, TimeUnit.MILLISECONDS);
+
+ DownloadService downloadService = getDownloadService();
+ if (downloadService == null || downloadService.getCurrentPlaying() == null) {
+ playlistFlipper.setDisplayedChild(1);
+ buttonBarFlipper.setDisplayedChild(1);
+ }
+
+ onDownloadListChanged();
+ onCurrentChanged();
+ onProgressChanged();
+ scrollToCurrent();
+ if (downloadService != null && downloadService.getKeepScreenOn()) {
+ getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+ } else {
+ getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+ }
+
+ if (visualizerView != null) {
+ visualizerView.setActive(downloadService != null && downloadService.getShowVisualization());
+ }
+
+ updateButtons();
+ }
+
+ private void updateButtons() {
+ boolean eqEnabled = getDownloadService() != null && getDownloadService().getEqualizerController() != null &&
+ getDownloadService().getEqualizerController().isEnabled();
+ equalizerButton.setTextColor(eqEnabled ? COLOR_BUTTON_ENABLED : COLOR_BUTTON_DISABLED);
+
+ if (visualizerView != null) {
+ visualizerButton.setTextColor(visualizerView.isActive() ? COLOR_BUTTON_ENABLED : COLOR_BUTTON_DISABLED);
+ }
+
+ boolean jukeboxEnabled = getDownloadService() != null && getDownloadService().isJukeboxEnabled();
+ jukeboxButton.setTextColor(jukeboxEnabled ? COLOR_BUTTON_ENABLED : COLOR_BUTTON_DISABLED);
+ }
+
+ // Scroll to current playing/downloading.
+ private void scrollToCurrent() {
+ if (getDownloadService() == null) {
+ return;
+ }
+
+ for (int i = 0; i < playlistView.getAdapter().getCount(); i++) {
+ if (currentPlaying == playlistView.getItemAtPosition(i)) {
+ playlistView.setSelectionFromTop(i, 40);
+ return;
+ }
+ }
+ DownloadFile currentDownloading = getDownloadService().getCurrentDownloading();
+ for (int i = 0; i < playlistView.getAdapter().getCount(); i++) {
+ if (currentDownloading == playlistView.getItemAtPosition(i)) {
+ playlistView.setSelectionFromTop(i, 40);
+ return;
+ }
+ }
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ executorService.shutdown();
+ if (visualizerView != null) {
+ visualizerView.setActive(false);
+ }
+ }
+
+ @Override
+ protected Dialog onCreateDialog(int id) {
+ if (id == DIALOG_SAVE_PLAYLIST) {
+ AlertDialog.Builder builder;
+
+ LayoutInflater inflater = (LayoutInflater) getSystemService(LAYOUT_INFLATER_SERVICE);
+ final View layout = inflater.inflate(R.layout.save_playlist, (ViewGroup) findViewById(R.id.save_playlist_root));
+ playlistNameView = (EditText) layout.findViewById(R.id.save_playlist_name);
+
+ builder = new AlertDialog.Builder(this);
+ builder.setTitle(R.string.download_playlist_title);
+ builder.setMessage(R.string.download_playlist_name);
+ builder.setPositiveButton(R.string.common_save, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int id) {
+ savePlaylistInBackground(String.valueOf(playlistNameView.getText()));
+ }
+ });
+ builder.setNegativeButton(R.string.common_cancel, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int id) {
+ dialog.cancel();
+ }
+ });
+ builder.setView(layout);
+ builder.setCancelable(true);
+
+ return builder.create();
+ } else {
+ return super.onCreateDialog(id);
+ }
+ }
+
+ @Override
+ protected void onPrepareDialog(int id, Dialog dialog) {
+ if (id == DIALOG_SAVE_PLAYLIST) {
+ String playlistName = getDownloadService().getSuggestedPlaylistName();
+ if (playlistName != null) {
+ playlistNameView.setText(playlistName);
+ } else {
+ DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
+ playlistNameView.setText(dateFormat.format(new Date()));
+ }
+ }
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ MenuInflater inflater = getMenuInflater();
+ inflater.inflate(R.menu.nowplaying, menu);
+ return true;
+ }
+
+ @Override
+ public boolean onPrepareOptionsMenu(Menu menu) {
+ MenuItem savePlaylist = menu.findItem(R.id.menu_save_playlist);
+ boolean savePlaylistEnabled = !Util.isOffline(this);
+ savePlaylist.setEnabled(savePlaylistEnabled);
+ savePlaylist.setVisible(savePlaylistEnabled);
+ MenuItem screenOption = menu.findItem(R.id.menu_screen_on_off);
+ if (getDownloadService().getKeepScreenOn()) {
+ screenOption.setTitle(R.string.download_menu_screen_off);
+ } else {
+ screenOption.setTitle(R.string.download_menu_screen_on);
+ }
+ return super.onPrepareOptionsMenu(menu);
+ }
+
+ @Override
+ public void onCreateContextMenu(ContextMenu menu, View view, ContextMenu.ContextMenuInfo menuInfo) {
+ super.onCreateContextMenu(menu, view, menuInfo);
+ if (view == playlistView) {
+ AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuInfo;
+ DownloadFile downloadFile = (DownloadFile) playlistView.getItemAtPosition(info.position);
+
+ MenuInflater inflater = getMenuInflater();
+ inflater.inflate(R.menu.nowplaying_context, menu);
+
+ if (downloadFile.getSong().getParent() == null) {
+ menu.findItem(R.id.menu_show_album).setVisible(false);
+ }
+ if (Util.isOffline(this)) {
+ menu.findItem(R.id.menu_lyrics).setVisible(false);
+ menu.findItem(R.id.menu_save_playlist).setVisible(false);
+ }
+ }
+ }
+
+ @Override
+ public boolean onContextItemSelected(MenuItem menuItem) {
+ AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuItem.getMenuInfo();
+ DownloadFile downloadFile = (DownloadFile) playlistView.getItemAtPosition(info.position);
+ return menuItemSelected(menuItem.getItemId(), downloadFile) || super.onContextItemSelected(menuItem);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem menuItem) {
+ return menuItemSelected(menuItem.getItemId(), null) || super.onOptionsItemSelected(menuItem);
+ }
+
+ private boolean menuItemSelected(int menuItemId, DownloadFile song) {
+ switch (menuItemId) {
+ case R.id.menu_show_album:
+ Intent intent = new Intent(this, SelectAlbumActivity.class);
+ intent.putExtra(Constants.INTENT_EXTRA_NAME_ID, song.getSong().getParent());
+ intent.putExtra(Constants.INTENT_EXTRA_NAME_NAME, song.getSong().getAlbum());
+ Util.startActivityWithoutTransition(this, intent);
+ return true;
+ case R.id.menu_lyrics:
+ intent = new Intent(this, LyricsActivity.class);
+ intent.putExtra(Constants.INTENT_EXTRA_NAME_ARTIST, song.getSong().getArtist());
+ intent.putExtra(Constants.INTENT_EXTRA_NAME_TITLE, song.getSong().getTitle());
+ Util.startActivityWithoutTransition(this, intent);
+ return true;
+ case R.id.menu_remove:
+ getDownloadService().remove(song);
+ onDownloadListChanged();
+ return true;
+ case R.id.menu_remove_all:
+ getDownloadService().setShufflePlayEnabled(false);
+ getDownloadService().clear();
+ onDownloadListChanged();
+ return true;
+ case R.id.menu_screen_on_off:
+ if (getDownloadService().getKeepScreenOn()) {
+ getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+ getDownloadService().setKeepScreenOn(false);
+ } else {
+ getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+ getDownloadService().setKeepScreenOn(true);
+ }
+ return true;
+ case R.id.menu_shuffle:
+ getDownloadService().shuffle();
+ Util.toast(this, R.string.download_menu_shuffle_notification);
+ return true;
+ case R.id.menu_save_playlist:
+ showDialog(DIALOG_SAVE_PLAYLIST);
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ private void update() {
+ if (getDownloadService() == null) {
+ return;
+ }
+
+ if (currentRevision != getDownloadService().getDownloadListUpdateRevision()) {
+ onDownloadListChanged();
+ }
+
+ if (currentPlaying != getDownloadService().getCurrentPlaying()) {
+ onCurrentChanged();
+ }
+
+ onProgressChanged();
+ }
+
+ private void savePlaylistInBackground(final String playlistName) {
+ Util.toast(DownloadActivity.this, getResources().getString(R.string.download_playlist_saving, playlistName));
+ getDownloadService().setSuggestedPlaylistName(playlistName);
+ new SilentBackgroundTask<Void>(this) {
+ @Override
+ protected Void doInBackground() throws Throwable {
+ List<MusicDirectory.Entry> entries = new LinkedList<MusicDirectory.Entry>();
+ for (DownloadFile downloadFile : getDownloadService().getDownloads()) {
+ entries.add(downloadFile.getSong());
+ }
+ MusicService musicService = MusicServiceFactory.getMusicService(DownloadActivity.this);
+ musicService.createPlaylist(null, playlistName, entries, DownloadActivity.this, null);
+ return null;
+ }
+
+ @Override
+ protected void done(Void result) {
+ Util.toast(DownloadActivity.this, R.string.download_playlist_done);
+ }
+
+ @Override
+ protected void error(Throwable error) {
+ String msg = getResources().getString(R.string.download_playlist_error) + " " + getErrorMessage(error);
+ Util.toast(DownloadActivity.this, msg);
+ }
+ }.execute();
+ }
+
+ private void toggleFullscreenAlbumArt() {
+ scrollToCurrent();
+ if (playlistFlipper.getDisplayedChild() == 1) {
+ playlistFlipper.setInAnimation(AnimationUtils.loadAnimation(this, R.anim.push_down_in));
+ playlistFlipper.setOutAnimation(AnimationUtils.loadAnimation(this, R.anim.push_down_out));
+ playlistFlipper.setDisplayedChild(0);
+ buttonBarFlipper.setInAnimation(AnimationUtils.loadAnimation(this, R.anim.push_down_in));
+ buttonBarFlipper.setOutAnimation(AnimationUtils.loadAnimation(this, R.anim.push_down_out));
+ buttonBarFlipper.setDisplayedChild(0);
+
+
+ } else {
+ playlistFlipper.setInAnimation(AnimationUtils.loadAnimation(this, R.anim.push_up_in));
+ playlistFlipper.setOutAnimation(AnimationUtils.loadAnimation(this, R.anim.push_up_out));
+ playlistFlipper.setDisplayedChild(1);
+ buttonBarFlipper.setInAnimation(AnimationUtils.loadAnimation(this, R.anim.push_up_in));
+ buttonBarFlipper.setOutAnimation(AnimationUtils.loadAnimation(this, R.anim.push_up_out));
+ buttonBarFlipper.setDisplayedChild(1);
+ }
+ }
+
+ private void start() {
+ DownloadService service = getDownloadService();
+ PlayerState state = service.getPlayerState();
+ if (state == PAUSED || state == COMPLETED) {
+ service.start();
+ } else if (state == STOPPED || state == IDLE) {
+ warnIfNetworkOrStorageUnavailable();
+ int current = service.getCurrentPlayingIndex();
+ // TODO: Use play() method.
+ if (current == -1) {
+ service.play(0);
+ } else {
+ service.play(current);
+ }
+ }
+ }
+
+ private void onDownloadListChanged() {
+ DownloadService downloadService = getDownloadService();
+ if (downloadService == null) {
+ return;
+ }
+
+ List<DownloadFile> list = downloadService.getDownloads();
+
+ playlistView.setAdapter(new SongListAdapter(list));
+ emptyTextView.setVisibility(list.isEmpty() ? View.VISIBLE : View.GONE);
+ currentRevision = downloadService.getDownloadListUpdateRevision();
+
+ switch (downloadService.getRepeatMode()) {
+ case OFF:
+ repeatButton.setImageResource(R.drawable.media_repeat_off);
+ break;
+ case ALL:
+ repeatButton.setImageResource(R.drawable.media_repeat_all);
+ break;
+ case SINGLE:
+ repeatButton.setImageResource(R.drawable.media_repeat_single);
+ break;
+ default:
+ break;
+ }
+ }
+
+ private void onCurrentChanged() {
+ if (getDownloadService() == null) {
+ return;
+ }
+
+ currentPlaying = getDownloadService().getCurrentPlaying();
+ if (currentPlaying != null) {
+ MusicDirectory.Entry song = currentPlaying.getSong();
+ songTitleTextView.setText(song.getTitle());
+ albumTextView.setText(song.getAlbum());
+ artistTextView.setText(song.getArtist());
+ getImageLoader().loadImage(albumArtImageView, song, true, true);
+ } else {
+ songTitleTextView.setText(null);
+ albumTextView.setText(null);
+ artistTextView.setText(null);
+ getImageLoader().loadImage(albumArtImageView, null, true, false);
+ }
+ }
+
+ private void onProgressChanged() {
+ if (getDownloadService() == null) {
+ return;
+ }
+
+ if (currentPlaying != null) {
+
+ int millisPlayed = Math.max(0, getDownloadService().getPlayerPosition());
+ Integer duration = getDownloadService().getPlayerDuration();
+ int millisTotal = duration == null ? 0 : duration;
+
+ positionTextView.setText(Util.formatDuration(millisPlayed / 1000));
+ durationTextView.setText(Util.formatDuration(millisTotal / 1000));
+ progressBar.setMax(millisTotal == 0 ? 100 : millisTotal); // Work-around for apparent bug.
+ progressBar.setProgress(millisPlayed);
+ progressBar.setSlidingEnabled(currentPlaying.isCompleteFileAvailable() || getDownloadService().isJukeboxEnabled());
+ } else {
+ positionTextView.setText("0:00");
+ durationTextView.setText("-:--");
+ progressBar.setProgress(0);
+ progressBar.setSlidingEnabled(false);
+ }
+
+ PlayerState playerState = getDownloadService().getPlayerState();
+
+ switch (playerState) {
+ case DOWNLOADING:
+ long bytes = currentPlaying.getPartialFile().length();
+ statusTextView.setText(getResources().getString(R.string.download_playerstate_downloading, Util.formatLocalizedBytes(bytes, this)));
+ break;
+ case PREPARING:
+ statusTextView.setText(R.string.download_playerstate_buffering);
+ break;
+ case STARTED:
+ if (getDownloadService().isShufflePlayEnabled()) {
+ statusTextView.setText(R.string.download_playerstate_playing_shuffle);
+ } else {
+ statusTextView.setText(null);
+ }
+ break;
+ default:
+ statusTextView.setText(null);
+ break;
+ }
+
+ switch (playerState) {
+ case STARTED:
+ pauseButton.setVisibility(View.VISIBLE);
+ stopButton.setVisibility(View.GONE);
+ startButton.setVisibility(View.GONE);
+ break;
+ case DOWNLOADING:
+ case PREPARING:
+ pauseButton.setVisibility(View.GONE);
+ stopButton.setVisibility(View.VISIBLE);
+ startButton.setVisibility(View.GONE);
+ break;
+ default:
+ pauseButton.setVisibility(View.GONE);
+ stopButton.setVisibility(View.GONE);
+ startButton.setVisibility(View.VISIBLE);
+ break;
+ }
+
+ jukeboxButton.setTextColor(getDownloadService().isJukeboxEnabled() ? COLOR_BUTTON_ENABLED : COLOR_BUTTON_DISABLED);
+ }
+
+ private class SongListAdapter extends ArrayAdapter<DownloadFile> {
+ public SongListAdapter(List<DownloadFile> entries) {
+ super(DownloadActivity.this, android.R.layout.simple_list_item_1, entries);
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ SongView view;
+ if (convertView != null && convertView instanceof SongView) {
+ view = (SongView) convertView;
+ } else {
+ view = new SongView(DownloadActivity.this);
+ }
+ DownloadFile downloadFile = getItem(position);
+ view.setSong(downloadFile.getSong(), false);
+ return view;
+ }
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent me) {
+ return gestureScanner.onTouchEvent(me);
+ }
+
+ @Override
+ public boolean onDown(MotionEvent me) {
+ return false;
+ }
+
+ @Override
+ public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
+
+ DownloadService downloadService = getDownloadService();
+ if (downloadService == null) {
+ return false;
+ }
+
+ // Right to Left swipe
+ if (e1.getX() - e2.getX() > swipeDistance && Math.abs(velocityX) > swipeVelocity) {
+ warnIfNetworkOrStorageUnavailable();
+ if (downloadService.getCurrentPlayingIndex() < downloadService.size() - 1) {
+ downloadService.next();
+ onCurrentChanged();
+ onProgressChanged();
+ }
+ return true;
+ }
+
+ // Left to Right swipe
+ if (e2.getX() - e1.getX() > swipeDistance && Math.abs(velocityX) > swipeVelocity) {
+ warnIfNetworkOrStorageUnavailable();
+ downloadService.previous();
+ onCurrentChanged();
+ onProgressChanged();
+ return true;
+ }
+
+ // Top to Bottom swipe
+ if (e2.getY() - e1.getY() > swipeDistance && Math.abs(velocityY) > swipeVelocity) {
+ warnIfNetworkOrStorageUnavailable();
+ downloadService.seekTo(downloadService.getPlayerPosition() + 30000);
+ onProgressChanged();
+ return true;
+ }
+
+ // Bottom to Top swipe
+ if (e1.getY() - e2.getY() > swipeDistance && Math.abs(velocityY) > swipeVelocity) {
+ warnIfNetworkOrStorageUnavailable();
+ downloadService.seekTo(downloadService.getPlayerPosition() - 8000);
+ onProgressChanged();
+ return true;
+ }
+
+ return false;
+ }
+
+ @Override
+ public void onLongPress(MotionEvent e) {
+ }
+
+ @Override
+ public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
+ return false;
+ }
+
+ @Override
+ public void onShowPress(MotionEvent e) {
+ }
+
+ @Override
+ public boolean onSingleTapUp(MotionEvent e) {
+ return false;
+ }
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/EqualizerActivity.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/EqualizerActivity.java
new file mode 100644
index 00000000..daf6193e
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/EqualizerActivity.java
@@ -0,0 +1,181 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2011 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.activity;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import android.app.Activity;
+import android.media.audiofx.Equalizer;
+import android.os.Bundle;
+import android.view.ContextMenu;
+import android.view.LayoutInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.CheckBox;
+import android.widget.CompoundButton;
+import android.widget.LinearLayout;
+import android.widget.SeekBar;
+import android.widget.TextView;
+import net.sourceforge.subsonic.androidapp.R;
+import net.sourceforge.subsonic.androidapp.audiofx.EqualizerController;
+import net.sourceforge.subsonic.androidapp.service.DownloadServiceImpl;
+
+/**
+ * Equalizer controls.
+ *
+ * @author Sindre Mehus
+ * @version $Id$
+ */
+public class EqualizerActivity extends Activity {
+
+ private static final int MENU_GROUP_PRESET = 100;
+
+ private final Map<Short, SeekBar> bars = new HashMap<Short, SeekBar>();
+ private EqualizerController equalizerController;
+ private Equalizer equalizer;
+
+ @Override
+ public void onCreate(Bundle bundle) {
+ super.onCreate(bundle);
+ setContentView(R.layout.equalizer);
+ equalizerController = DownloadServiceImpl.getInstance().getEqualizerController();
+ equalizer = equalizerController.getEqualizer();
+
+ initEqualizer();
+
+ final View presetButton = findViewById(R.id.equalizer_preset);
+ registerForContextMenu(presetButton);
+ presetButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ presetButton.showContextMenu();
+ }
+ });
+
+ CheckBox enabledCheckBox = (CheckBox) findViewById(R.id.equalizer_enabled);
+ enabledCheckBox.setChecked(equalizer.getEnabled());
+ enabledCheckBox.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
+ @Override
+ public void onCheckedChanged(CompoundButton compoundButton, boolean b) {
+ setEqualizerEnabled(b);
+ }
+ });
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ equalizerController.saveSettings();
+ }
+
+ @Override
+ public void onCreateContextMenu(ContextMenu menu, View view, ContextMenu.ContextMenuInfo menuInfo) {
+ super.onCreateContextMenu(menu, view, menuInfo);
+
+ short currentPreset;
+ try {
+ currentPreset = equalizer.getCurrentPreset();
+ } catch (Exception x) {
+ currentPreset = -1;
+ }
+
+ for (short preset = 0; preset < equalizer.getNumberOfPresets(); preset++) {
+ MenuItem menuItem = menu.add(MENU_GROUP_PRESET, preset, preset, equalizer.getPresetName(preset));
+ if (preset == currentPreset) {
+ menuItem.setChecked(true);
+ }
+ }
+ menu.setGroupCheckable(MENU_GROUP_PRESET, true, true);
+ }
+
+ @Override
+ public boolean onContextItemSelected(MenuItem menuItem) {
+ short preset = (short) menuItem.getItemId();
+ equalizer.usePreset(preset);
+ updateBars();
+ return true;
+ }
+
+ private void setEqualizerEnabled(boolean enabled) {
+ equalizer.setEnabled(enabled);
+ updateBars();
+ }
+
+ private void updateBars() {
+
+ for (Map.Entry<Short, SeekBar> entry : bars.entrySet()) {
+ short band = entry.getKey();
+ SeekBar bar = entry.getValue();
+ bar.setEnabled(equalizer.getEnabled());
+ short minEQLevel = equalizer.getBandLevelRange()[0];
+ bar.setProgress(equalizer.getBandLevel(band) - minEQLevel);
+ }
+ }
+
+ private void initEqualizer() {
+ LinearLayout layout = (LinearLayout) findViewById(R.id.equalizer_layout);
+
+ final short minEQLevel = equalizer.getBandLevelRange()[0];
+ final short maxEQLevel = equalizer.getBandLevelRange()[1];
+
+ for (short i = 0; i < equalizer.getNumberOfBands(); i++) {
+ final short band = i;
+
+ View bandBar = LayoutInflater.from(this).inflate(R.layout.equalizer_bar, null);
+ TextView freqTextView = (TextView) bandBar.findViewById(R.id.equalizer_frequency);
+ final TextView levelTextView = (TextView) bandBar.findViewById(R.id.equalizer_level);
+ SeekBar bar = (SeekBar) bandBar.findViewById(R.id.equalizer_bar);
+
+ freqTextView.setText((equalizer.getCenterFreq(band) / 1000) + " Hz");
+
+ bars.put(band, bar);
+ bar.setMax(maxEQLevel - minEQLevel);
+ short level = equalizer.getBandLevel(band);
+ bar.setProgress(level - minEQLevel);
+ bar.setEnabled(equalizer.getEnabled());
+ updateLevelText(levelTextView, level);
+
+ bar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
+ @Override
+ public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
+ short level = (short) (progress + minEQLevel);
+ if (fromUser) {
+ equalizer.setBandLevel(band, level);
+ }
+ updateLevelText(levelTextView, level);
+ }
+
+ @Override
+ public void onStartTrackingTouch(SeekBar seekBar) {
+ }
+
+ @Override
+ public void onStopTrackingTouch(SeekBar seekBar) {
+ }
+ });
+ layout.addView(bandBar);
+ }
+ }
+
+ private void updateLevelText(TextView levelTextView, short level) {
+ levelTextView.setText((level > 0 ? "+" : "") + level / 100 + " dB");
+ }
+
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/HelpActivity.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/HelpActivity.java
new file mode 100644
index 00000000..4b2eb63b
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/HelpActivity.java
@@ -0,0 +1,117 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+
+package net.sourceforge.subsonic.androidapp.activity;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.view.KeyEvent;
+import android.view.View;
+import android.view.Window;
+import android.webkit.WebView;
+import android.webkit.WebViewClient;
+import android.widget.Button;
+import net.sourceforge.subsonic.androidapp.R;
+import net.sourceforge.subsonic.androidapp.util.Util;
+
+/**
+ * An HTML-based help screen with Back and Done buttons at the bottom.
+ *
+ * @author Sindre Mehus
+ */
+public final class HelpActivity extends Activity {
+
+ private WebView webView;
+ private Button backButton;
+
+ @Override
+ protected void onCreate(Bundle bundle) {
+ super.onCreate(bundle);
+ getWindow().requestFeature(Window.FEATURE_INDETERMINATE_PROGRESS);
+
+ setContentView(R.layout.help);
+
+ webView = (WebView) findViewById(R.id.help_contents);
+ webView.getSettings().setJavaScriptEnabled(true);
+ webView.setWebViewClient(new HelpClient());
+ if (bundle != null) {
+ webView.restoreState(bundle);
+ } else {
+ webView.loadUrl(getResources().getString(R.string.help_url));
+ }
+
+ backButton = (Button) findViewById(R.id.help_back);
+ backButton.setOnClickListener(new Button.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ webView.goBack();
+ }
+ });
+
+ Button doneButton = (Button) findViewById(R.id.help_close);
+ doneButton.setOnClickListener(new Button.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ finish();
+ }
+ });
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ }
+
+ @Override
+ protected void onSaveInstanceState(Bundle state) {
+ webView.saveState(state);
+ }
+
+ @Override
+ public boolean onKeyDown(int keyCode, KeyEvent event) {
+ if (keyCode == KeyEvent.KEYCODE_BACK) {
+ if (webView.canGoBack()) {
+ webView.goBack();
+ return true;
+ }
+ }
+ return super.onKeyDown(keyCode, event);
+ }
+
+ private final class HelpClient extends WebViewClient {
+ @Override
+ public void onLoadResource(WebView webView, String url) {
+ setProgressBarIndeterminateVisibility(true);
+ setTitle(getResources().getString(R.string.help_loading));
+ super.onLoadResource(webView, url);
+ }
+
+ @Override
+ public void onPageFinished(WebView view, String url) {
+ setProgressBarIndeterminateVisibility(false);
+ setTitle(view.getTitle());
+ backButton.setEnabled(view.canGoBack());
+ }
+
+ @Override
+ public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) {
+ Util.toast(HelpActivity.this, description);
+ }
+ }
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/LyricsActivity.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/LyricsActivity.java
new file mode 100644
index 00000000..0ec75e2c
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/LyricsActivity.java
@@ -0,0 +1,72 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+
+package net.sourceforge.subsonic.androidapp.activity;
+
+import android.os.Bundle;
+import android.widget.TextView;
+import net.sourceforge.subsonic.androidapp.R;
+import net.sourceforge.subsonic.androidapp.domain.Lyrics;
+import net.sourceforge.subsonic.androidapp.service.MusicService;
+import net.sourceforge.subsonic.androidapp.service.MusicServiceFactory;
+import net.sourceforge.subsonic.androidapp.util.BackgroundTask;
+import net.sourceforge.subsonic.androidapp.util.Constants;
+import net.sourceforge.subsonic.androidapp.util.TabActivityBackgroundTask;
+
+/**
+ * Displays song lyrics.
+ *
+ * @author Sindre Mehus
+ */
+public final class LyricsActivity extends SubsonicTabActivity {
+
+ @Override
+ protected void onCreate(Bundle bundle) {
+ super.onCreate(bundle);
+ setContentView(R.layout.lyrics);
+ load();
+ }
+
+ private void load() {
+ BackgroundTask<Lyrics> task = new TabActivityBackgroundTask<Lyrics>(this) {
+ @Override
+ protected Lyrics doInBackground() throws Throwable {
+ String artist = getIntent().getStringExtra(Constants.INTENT_EXTRA_NAME_ARTIST);
+ String title = getIntent().getStringExtra(Constants.INTENT_EXTRA_NAME_TITLE);
+ MusicService musicService = MusicServiceFactory.getMusicService(LyricsActivity.this);
+ return musicService.getLyrics(artist, title, LyricsActivity.this, this);
+ }
+
+ @Override
+ protected void done(Lyrics result) {
+ TextView artistView = (TextView) findViewById(R.id.lyrics_artist);
+ TextView titleView = (TextView) findViewById(R.id.lyrics_title);
+ TextView textView = (TextView) findViewById(R.id.lyrics_text);
+ if (result != null && result.getArtist() != null) {
+ artistView.setText(result.getArtist());
+ titleView.setText(result.getTitle());
+ textView.setText(result.getText());
+ } else {
+ artistView.setText(R.string.lyrics_nomatch);
+ }
+ }
+ };
+ task.execute();
+ }
+} \ No newline at end of file
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/MainActivity.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/MainActivity.java
new file mode 100644
index 00000000..c63a391b
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/MainActivity.java
@@ -0,0 +1,258 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+
+package net.sourceforge.subsonic.androidapp.activity;
+
+import java.util.Arrays;
+
+import net.sourceforge.subsonic.androidapp.R;
+import net.sourceforge.subsonic.androidapp.service.DownloadService;
+import net.sourceforge.subsonic.androidapp.service.DownloadServiceImpl;
+import net.sourceforge.subsonic.androidapp.util.Constants;
+import net.sourceforge.subsonic.androidapp.util.MergeAdapter;
+import net.sourceforge.subsonic.androidapp.util.Util;
+import net.sourceforge.subsonic.androidapp.util.FileUtil;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.os.Bundle;
+import android.preference.PreferenceManager;
+import android.view.ContextMenu;
+import android.view.LayoutInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.AdapterView;
+import android.widget.ImageButton;
+import android.widget.ListView;
+import android.widget.TextView;
+
+public class MainActivity extends SubsonicTabActivity {
+
+ private static final int MENU_GROUP_SERVER = 10;
+ private static final int MENU_ITEM_SERVER_1 = 101;
+ private static final int MENU_ITEM_SERVER_2 = 102;
+ private static final int MENU_ITEM_SERVER_3 = 103;
+ private static final int MENU_ITEM_OFFLINE = 104;
+
+ private String theme;
+
+ private static boolean infoDialogDisplayed;
+
+ /**
+ * Called when the activity is first created.
+ */
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ if (getIntent().hasExtra(Constants.INTENT_EXTRA_NAME_EXIT)) {
+ exit();
+ }
+ setContentView(R.layout.main);
+
+ loadSettings();
+
+ View buttons = LayoutInflater.from(this).inflate(R.layout.main_buttons, null);
+
+ final View serverButton = buttons.findViewById(R.id.main_select_server);
+ final TextView serverTextView = (TextView) serverButton.findViewById(R.id.main_select_server_2);
+
+ final View albumsTitle = buttons.findViewById(R.id.main_albums);
+ final View albumsNewestButton = buttons.findViewById(R.id.main_albums_newest);
+ final View albumsRandomButton = buttons.findViewById(R.id.main_albums_random);
+ final View albumsHighestButton = buttons.findViewById(R.id.main_albums_highest);
+ final View albumsRecentButton = buttons.findViewById(R.id.main_albums_recent);
+ final View albumsFrequentButton = buttons.findViewById(R.id.main_albums_frequent);
+
+ final View dummyView = findViewById(R.id.main_dummy);
+
+ int instance = Util.getActiveServer(this);
+ String name = Util.getServerName(this, instance);
+ serverTextView.setText(name);
+
+ ListView list = (ListView) findViewById(R.id.main_list);
+
+ MergeAdapter adapter = new MergeAdapter();
+ adapter.addViews(Arrays.asList(serverButton), true);
+ if (!Util.isOffline(this)) {
+ adapter.addView(albumsTitle, false);
+ adapter.addViews(Arrays.asList(albumsNewestButton, albumsRandomButton, albumsHighestButton, albumsRecentButton, albumsFrequentButton), true);
+ }
+ list.setAdapter(adapter);
+ registerForContextMenu(dummyView);
+
+ list.setOnItemClickListener(new AdapterView.OnItemClickListener() {
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ if (view == serverButton) {
+ dummyView.showContextMenu();
+ } else if (view == albumsNewestButton) {
+ showAlbumList("newest");
+ } else if (view == albumsRandomButton) {
+ showAlbumList("random");
+ } else if (view == albumsHighestButton) {
+ showAlbumList("highest");
+ } else if (view == albumsRecentButton) {
+ showAlbumList("recent");
+ } else if (view == albumsFrequentButton) {
+ showAlbumList("frequent");
+ }
+ }
+ });
+
+ // Title: Subsonic
+ setTitle(R.string.common_appname);
+
+ // Button 1: shuffle
+ ImageButton actionShuffleButton = (ImageButton)findViewById(R.id.action_button_1);
+ actionShuffleButton.setImageResource(R.drawable.action_shuffle);
+ actionShuffleButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ Intent intent = new Intent(MainActivity.this, DownloadActivity.class);
+ intent.putExtra(Constants.INTENT_EXTRA_NAME_SHUFFLE, true);
+ Util.startActivityWithoutTransition(MainActivity.this, intent);
+ }
+ });
+
+ // Button 2: search
+ ImageButton actionSearchButton = (ImageButton)findViewById(R.id.action_button_2);
+ actionSearchButton.setImageResource(R.drawable.action_search);
+ actionSearchButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ Intent intent = new Intent(MainActivity.this, SearchActivity.class);
+ intent.putExtra(Constants.INTENT_EXTRA_REQUEST_SEARCH, true);
+ Util.startActivityWithoutTransition(MainActivity.this, intent);
+ }
+ });
+
+ // Remember the current theme.
+ theme = Util.getTheme(this);
+
+ showInfoDialog();
+ }
+
+ private void loadSettings() {
+ PreferenceManager.setDefaultValues(this, R.xml.settings, false);
+ SharedPreferences prefs = Util.getPreferences(this);
+ if (!prefs.contains(Constants.PREFERENCES_KEY_CACHE_LOCATION)) {
+ SharedPreferences.Editor editor = prefs.edit();
+ editor.putString(Constants.PREFERENCES_KEY_CACHE_LOCATION, FileUtil.getDefaultMusicDirectory().getPath());
+ editor.commit();
+ }
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+
+ // Restart activity if theme has changed.
+ if (theme != null && !theme.equals(Util.getTheme(this))) {
+ restart();
+ }
+ }
+
+ @Override
+ public void onCreateContextMenu(ContextMenu menu, View view, ContextMenu.ContextMenuInfo menuInfo) {
+ super.onCreateContextMenu(menu, view, menuInfo);
+
+ MenuItem menuItem1 = menu.add(MENU_GROUP_SERVER, MENU_ITEM_SERVER_1, MENU_ITEM_SERVER_1, Util.getServerName(this, 1));
+ MenuItem menuItem2 = menu.add(MENU_GROUP_SERVER, MENU_ITEM_SERVER_2, MENU_ITEM_SERVER_2, Util.getServerName(this, 2));
+ MenuItem menuItem3 = menu.add(MENU_GROUP_SERVER, MENU_ITEM_SERVER_3, MENU_ITEM_SERVER_3, Util.getServerName(this, 3));
+ MenuItem menuItem4 = menu.add(MENU_GROUP_SERVER, MENU_ITEM_OFFLINE, MENU_ITEM_OFFLINE, Util.getServerName(this, 0));
+ menu.setGroupCheckable(MENU_GROUP_SERVER, true, true);
+ menu.setHeaderTitle(R.string.main_select_server);
+
+ switch (Util.getActiveServer(this)) {
+ case 0:
+ menuItem4.setChecked(true);
+ break;
+ case 1:
+ menuItem1.setChecked(true);
+ break;
+ case 2:
+ menuItem2.setChecked(true);
+ break;
+ case 3:
+ menuItem3.setChecked(true);
+ break;
+ }
+ }
+
+ @Override
+ public boolean onContextItemSelected(MenuItem menuItem) {
+ switch (menuItem.getItemId()) {
+ case MENU_ITEM_OFFLINE:
+ setActiveServer(0);
+ break;
+ case MENU_ITEM_SERVER_1:
+ setActiveServer(1);
+ break;
+ case MENU_ITEM_SERVER_2:
+ setActiveServer(2);
+ break;
+ case MENU_ITEM_SERVER_3:
+ setActiveServer(3);
+ break;
+ default:
+ return super.onContextItemSelected(menuItem);
+ }
+
+ // Restart activity
+ restart();
+ return true;
+ }
+
+ private void setActiveServer(int instance) {
+ if (Util.getActiveServer(this) != instance) {
+ DownloadService service = getDownloadService();
+ if (service != null) {
+ service.clearIncomplete();
+ }
+ Util.setActiveServer(this, instance);
+ }
+ }
+
+ private void restart() {
+ Intent intent = new Intent(this, MainActivity.class);
+ intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
+ Util.startActivityWithoutTransition(this, intent);
+ }
+
+ private void exit() {
+ stopService(new Intent(this, DownloadServiceImpl.class));
+ finish();
+ }
+
+ private void showInfoDialog() {
+ if (!infoDialogDisplayed) {
+ infoDialogDisplayed = true;
+ if (Util.getRestUrl(this, null).contains("demo.subsonic.org")) {
+ Util.info(this, R.string.main_welcome_title, R.string.main_welcome_text);
+ }
+ }
+ }
+
+ private void showAlbumList(String type) {
+ Intent intent = new Intent(this, SelectAlbumActivity.class);
+ intent.putExtra(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TYPE, type);
+ intent.putExtra(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_SIZE, 20);
+ intent.putExtra(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_OFFSET, 0);
+ Util.startActivityWithoutTransition(this, intent);
+ }
+} \ No newline at end of file
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/PlayVideoActivity.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/PlayVideoActivity.java
new file mode 100644
index 00000000..ea332ca0
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/PlayVideoActivity.java
@@ -0,0 +1,147 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+
+package net.sourceforge.subsonic.androidapp.activity;
+
+import java.lang.reflect.Method;
+
+import android.app.Activity;
+import android.graphics.Bitmap;
+import android.media.AudioManager;
+import android.os.Bundle;
+import android.util.Log;
+import android.view.Window;
+import android.webkit.WebView;
+import android.webkit.WebViewClient;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import net.sourceforge.subsonic.androidapp.R;
+import net.sourceforge.subsonic.androidapp.service.MusicServiceFactory;
+import net.sourceforge.subsonic.androidapp.util.Constants;
+import net.sourceforge.subsonic.androidapp.util.Util;
+
+/**
+ * Plays videos in a web page.
+ *
+ * @author Sindre Mehus
+ */
+public final class PlayVideoActivity extends Activity {
+
+ private static final String TAG = PlayVideoActivity.class.getSimpleName();
+ private WebView webView;
+
+ @Override
+ protected void onCreate(Bundle bundle) {
+ super.onCreate(bundle);
+ getWindow().requestFeature(Window.FEATURE_NO_TITLE);
+ setVolumeControlStream(AudioManager.STREAM_MUSIC);
+
+ setContentView(R.layout.play_video);
+
+ webView = (WebView) findViewById(R.id.play_video_contents);
+ webView.getSettings().setJavaScriptEnabled(true);
+ webView.getSettings().setPluginsEnabled(true);
+ webView.getSettings().setAllowFileAccess(true);
+ webView.getSettings().setSupportZoom(true);
+ webView.getSettings().setBuiltInZoomControls(true);
+
+ webView.setWebViewClient(new Client());
+ if (bundle != null) {
+ webView.restoreState(bundle);
+ } else {
+ webView.loadUrl(getVideoUrl());
+ }
+
+ // Show warning if Flash plugin is not installed.
+ if (isFlashPluginInstalled()) {
+ Util.toast(this, R.string.play_video_loading, false);
+ } else {
+ Util.toast(this, R.string.play_video_noplugin, false);
+ }
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ callHiddenWebViewMethod("onPause");
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ callHiddenWebViewMethod("onResume");
+ }
+
+ private String getVideoUrl() {
+ String id = getIntent().getStringExtra(Constants.INTENT_EXTRA_NAME_ID);
+ return MusicServiceFactory.getMusicService(this).getVideoUrl(this, id);
+ }
+
+ @Override
+ protected void onSaveInstanceState(Bundle state) {
+ webView.saveState(state);
+ }
+
+ private void callHiddenWebViewMethod(String name){
+ if( webView != null ){
+ try {
+ Method method = WebView.class.getMethod(name);
+ method.invoke(webView);
+ } catch (Throwable x) {
+ Log.e(TAG, "Failed to invoke " + name, x);
+ }
+ }
+ }
+
+ private boolean isFlashPluginInstalled() {
+ try {
+ PackageInfo packageInfo = getPackageManager().getPackageInfo("com.adobe.flashplayer", 0);
+ return packageInfo != null;
+ } catch (PackageManager.NameNotFoundException x) {
+ return false;
+ }
+ }
+
+ private final class Client extends WebViewClient {
+
+ @Override
+ public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) {
+ Util.toast(PlayVideoActivity.this, description);
+ Log.e(TAG, "Error: " + description);
+ }
+
+ @Override
+ public void onLoadResource(WebView view, String url) {
+ super.onLoadResource(view, url);
+ Log.d(TAG, "onLoadResource: " + url);
+ }
+
+ @Override
+ public void onPageStarted(WebView view, String url, Bitmap favicon) {
+ super.onPageStarted(view, url, favicon);
+ Log.d(TAG, "onPageStarted: " + url);
+ }
+
+ @Override
+ public void onPageFinished(WebView view, String url) {
+ super.onPageFinished(view, url);
+ Log.d(TAG, "onPageFinished: " + url);
+ }
+ }
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/QueryReceiverActivity.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/QueryReceiverActivity.java
new file mode 100644
index 00000000..35b5ccaf
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/QueryReceiverActivity.java
@@ -0,0 +1,56 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+
+package net.sourceforge.subsonic.androidapp.activity;
+
+import android.app.Activity;
+import android.app.SearchManager;
+import android.content.Intent;
+import android.os.Bundle;
+import android.provider.SearchRecentSuggestions;
+import net.sourceforge.subsonic.androidapp.util.Constants;
+import net.sourceforge.subsonic.androidapp.util.Util;
+import net.sourceforge.subsonic.androidapp.provider.SearchSuggestionProvider;
+
+/**
+ * Receives search queries and forwards to the SelectAlbumActivity.
+ *
+ * @author Sindre Mehus
+ */
+public class QueryReceiverActivity extends Activity {
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ String query = getIntent().getStringExtra(SearchManager.QUERY);
+
+ if (query != null) {
+ SearchRecentSuggestions suggestions = new SearchRecentSuggestions(this, SearchSuggestionProvider.AUTHORITY,
+ SearchSuggestionProvider.MODE);
+ suggestions.saveRecentQuery(query, null);
+
+ Intent intent = new Intent(QueryReceiverActivity.this, SearchActivity.class);
+ intent.putExtra(Constants.INTENT_EXTRA_NAME_QUERY, query);
+ Util.startActivityWithoutTransition(QueryReceiverActivity.this, intent);
+ }
+ finish();
+ Util.disablePendingTransition(this);
+ }
+} \ No newline at end of file
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/SearchActivity.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/SearchActivity.java
new file mode 100644
index 00000000..73b787a0
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/SearchActivity.java
@@ -0,0 +1,368 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+
+package net.sourceforge.subsonic.androidapp.activity;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Arrays;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.ContextMenu;
+import android.view.LayoutInflater;
+import android.view.MenuInflater;
+import android.view.View;
+import android.view.MenuItem;
+import android.widget.AdapterView;
+import android.widget.ImageButton;
+import android.widget.ListAdapter;
+import android.widget.ListView;
+import android.widget.TextView;
+import android.net.Uri;
+import net.sourceforge.subsonic.androidapp.R;
+import net.sourceforge.subsonic.androidapp.domain.Artist;
+import net.sourceforge.subsonic.androidapp.domain.MusicDirectory;
+import net.sourceforge.subsonic.androidapp.domain.SearchCritera;
+import net.sourceforge.subsonic.androidapp.domain.SearchResult;
+import net.sourceforge.subsonic.androidapp.service.MusicService;
+import net.sourceforge.subsonic.androidapp.service.MusicServiceFactory;
+import net.sourceforge.subsonic.androidapp.service.DownloadService;
+import net.sourceforge.subsonic.androidapp.util.ArtistAdapter;
+import net.sourceforge.subsonic.androidapp.util.BackgroundTask;
+import net.sourceforge.subsonic.androidapp.util.Constants;
+import net.sourceforge.subsonic.androidapp.util.EntryAdapter;
+import net.sourceforge.subsonic.androidapp.util.MergeAdapter;
+import net.sourceforge.subsonic.androidapp.util.TabActivityBackgroundTask;
+import net.sourceforge.subsonic.androidapp.util.Util;
+
+/**
+ * Performs searches and displays the matching artists, albums and songs.
+ *
+ * @author Sindre Mehus
+ */
+public class SearchActivity extends SubsonicTabActivity {
+
+ private static final int DEFAULT_ARTISTS = 3;
+ private static final int DEFAULT_ALBUMS = 5;
+ private static final int DEFAULT_SONGS = 10;
+
+ private static final int MAX_ARTISTS = 10;
+ private static final int MAX_ALBUMS = 20;
+ private static final int MAX_SONGS = 25;
+ private ListView list;
+
+ private View artistsHeading;
+ private View albumsHeading;
+ private View songsHeading;
+ private TextView searchButton;
+ private View moreArtistsButton;
+ private View moreAlbumsButton;
+ private View moreSongsButton;
+ private SearchResult searchResult;
+ private MergeAdapter mergeAdapter;
+ private ArtistAdapter artistAdapter;
+ private ListAdapter moreArtistsAdapter;
+ private EntryAdapter albumAdapter;
+ private ListAdapter moreAlbumsAdapter;
+ private ListAdapter moreSongsAdapter;
+ private EntryAdapter songAdapter;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.search);
+
+ setTitle(R.string.search_title);
+
+ View buttons = LayoutInflater.from(this).inflate(R.layout.search_buttons, null);
+
+ artistsHeading = buttons.findViewById(R.id.search_artists);
+ albumsHeading = buttons.findViewById(R.id.search_albums);
+ songsHeading = buttons.findViewById(R.id.search_songs);
+
+ searchButton = (TextView) buttons.findViewById(R.id.search_search);
+ moreArtistsButton = buttons.findViewById(R.id.search_more_artists);
+ moreAlbumsButton = buttons.findViewById(R.id.search_more_albums);
+ moreSongsButton = buttons.findViewById(R.id.search_more_songs);
+
+ list = (ListView) findViewById(R.id.search_list);
+
+ list.setOnItemClickListener(new AdapterView.OnItemClickListener() {
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ if (view == searchButton) {
+ onSearchRequested();
+ } else if (view == moreArtistsButton) {
+ expandArtists();
+ } else if (view == moreAlbumsButton) {
+ expandAlbums();
+ } else if (view == moreSongsButton) {
+ expandSongs();
+ } else {
+ Object item = parent.getItemAtPosition(position);
+ if (item instanceof Artist) {
+ onArtistSelected((Artist) item);
+ } else if (item instanceof MusicDirectory.Entry) {
+ MusicDirectory.Entry entry = (MusicDirectory.Entry) item;
+ if (entry.isDirectory()) {
+ onAlbumSelected(entry, false);
+ } else if (entry.isVideo()) {
+ onVideoSelected(entry);
+ } else {
+ onSongSelected(entry, false, true, true, false);
+ }
+
+ }
+ }
+ }
+ });
+ registerForContextMenu(list);
+
+ // Button 1: gone
+ findViewById(R.id.action_button_1).setVisibility(View.GONE);
+
+ // Button 2: search
+ final ImageButton actionSearchButton = (ImageButton)findViewById(R.id.action_button_2);
+ actionSearchButton.setImageResource(R.drawable.action_search);
+ actionSearchButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ onSearchRequested();
+ }
+ });
+
+ onNewIntent(getIntent());
+ }
+
+ @Override
+ protected void onNewIntent(Intent intent) {
+ super.onNewIntent(intent);
+ String query = intent.getStringExtra(Constants.INTENT_EXTRA_NAME_QUERY);
+ boolean autoplay = intent.getBooleanExtra(Constants.INTENT_EXTRA_NAME_AUTOPLAY, false);
+ boolean requestsearch = intent.getBooleanExtra(Constants.INTENT_EXTRA_REQUEST_SEARCH, false);
+
+ if (query != null) {
+ mergeAdapter = new MergeAdapter();
+ list.setAdapter(mergeAdapter);
+ search(query, autoplay);
+ } else {
+ populateList();
+ if (requestsearch)
+ onSearchRequested();
+ }
+ }
+
+ @Override
+ public void onCreateContextMenu(ContextMenu menu, View view, ContextMenu.ContextMenuInfo menuInfo) {
+ super.onCreateContextMenu(menu, view, menuInfo);
+
+ AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuInfo;
+ Object selectedItem = list.getItemAtPosition(info.position);
+
+ boolean isArtist = selectedItem instanceof Artist;
+ boolean isAlbum = selectedItem instanceof MusicDirectory.Entry && ((MusicDirectory.Entry) selectedItem).isDirectory();
+ boolean isSong = selectedItem instanceof MusicDirectory.Entry && (!((MusicDirectory.Entry) selectedItem).isDirectory())
+ && (!((MusicDirectory.Entry) selectedItem).isVideo());
+
+ if (isArtist || isAlbum) {
+ MenuInflater inflater = getMenuInflater();
+ inflater.inflate(R.menu.select_album_context, menu);
+ } else if (isSong) {
+ MenuInflater inflater = getMenuInflater();
+ inflater.inflate(R.menu.select_song_context, menu);
+ }
+ }
+
+ @Override
+ public boolean onContextItemSelected(MenuItem menuItem) {
+ AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuItem.getMenuInfo();
+ Object selectedItem = list.getItemAtPosition(info.position);
+
+ Artist artist = selectedItem instanceof Artist ? (Artist) selectedItem : null;
+ MusicDirectory.Entry entry = selectedItem instanceof MusicDirectory.Entry ? (MusicDirectory.Entry) selectedItem : null;
+ String id = artist != null ? artist.getId() : entry.getId();
+
+ switch (menuItem.getItemId()) {
+ case R.id.album_menu_play_now:
+ downloadRecursively(id, false, false, true);
+ break;
+ case R.id.album_menu_play_last:
+ downloadRecursively(id, false, true, false);
+ break;
+ case R.id.album_menu_pin:
+ downloadRecursively(id, true, true, false);
+ break;
+ case R.id.song_menu_play_now:
+ onSongSelected(entry, false, false, true, false);
+ break;
+ case R.id.song_menu_play_next:
+ onSongSelected(entry, false, true, false, true);
+ break;
+ case R.id.song_menu_play_last:
+ onSongSelected(entry, false, true, false, false);
+ break;
+ default:
+ return super.onContextItemSelected(menuItem);
+ }
+
+ return true;
+ }
+
+ private void search(final String query, final boolean autoplay) {
+ BackgroundTask<SearchResult> task = new TabActivityBackgroundTask<SearchResult>(this) {
+ @Override
+ protected SearchResult doInBackground() throws Throwable {
+ SearchCritera criteria = new SearchCritera(query, MAX_ARTISTS, MAX_ALBUMS, MAX_SONGS);
+ MusicService service = MusicServiceFactory.getMusicService(SearchActivity.this);
+ return service.search(criteria, SearchActivity.this, this);
+ }
+
+ @Override
+ protected void done(SearchResult result) {
+ searchResult = result;
+ populateList();
+ if (autoplay) {
+ autoplay();
+ }
+
+ }
+ };
+ task.execute();
+ }
+
+ private void populateList() {
+ mergeAdapter = new MergeAdapter();
+ mergeAdapter.addView(searchButton, true);
+
+ if (searchResult != null) {
+ List<Artist> artists = searchResult.getArtists();
+ if (!artists.isEmpty()) {
+ mergeAdapter.addView(artistsHeading);
+ List<Artist> displayedArtists = new ArrayList<Artist>(artists.subList(0, Math.min(DEFAULT_ARTISTS, artists.size())));
+ artistAdapter = new ArtistAdapter(this, displayedArtists);
+ mergeAdapter.addAdapter(artistAdapter);
+ if (artists.size() > DEFAULT_ARTISTS) {
+ moreArtistsAdapter = mergeAdapter.addView(moreArtistsButton, true);
+ }
+ }
+
+ List<MusicDirectory.Entry> albums = searchResult.getAlbums();
+ if (!albums.isEmpty()) {
+ mergeAdapter.addView(albumsHeading);
+ List<MusicDirectory.Entry> displayedAlbums = new ArrayList<MusicDirectory.Entry>(albums.subList(0, Math.min(DEFAULT_ALBUMS, albums.size())));
+ albumAdapter = new EntryAdapter(this, getImageLoader(), displayedAlbums, false);
+ mergeAdapter.addAdapter(albumAdapter);
+ if (albums.size() > DEFAULT_ALBUMS) {
+ moreAlbumsAdapter = mergeAdapter.addView(moreAlbumsButton, true);
+ }
+ }
+
+ List<MusicDirectory.Entry> songs = searchResult.getSongs();
+ if (!songs.isEmpty()) {
+ mergeAdapter.addView(songsHeading);
+ List<MusicDirectory.Entry> displayedSongs = new ArrayList<MusicDirectory.Entry>(songs.subList(0, Math.min(DEFAULT_SONGS, songs.size())));
+ songAdapter = new EntryAdapter(this, getImageLoader(), displayedSongs, false);
+ mergeAdapter.addAdapter(songAdapter);
+ if (songs.size() > DEFAULT_SONGS) {
+ moreSongsAdapter = mergeAdapter.addView(moreSongsButton, true);
+ }
+ }
+
+ boolean empty = searchResult.getArtists().isEmpty() && searchResult.getAlbums().isEmpty() && searchResult.getSongs().isEmpty();
+ searchButton.setText(empty ? R.string.search_no_match : R.string.search_search);
+ }
+
+ list.setAdapter(mergeAdapter);
+ }
+
+ private void expandArtists() {
+ artistAdapter.clear();
+ for (Artist artist : searchResult.getArtists()) {
+ artistAdapter.add(artist);
+ }
+ artistAdapter.notifyDataSetChanged();
+ mergeAdapter.removeAdapter(moreArtistsAdapter);
+ mergeAdapter.notifyDataSetChanged();
+ }
+
+ private void expandAlbums() {
+ albumAdapter.clear();
+ for (MusicDirectory.Entry album : searchResult.getAlbums()) {
+ albumAdapter.add(album);
+ }
+ albumAdapter.notifyDataSetChanged();
+ mergeAdapter.removeAdapter(moreAlbumsAdapter);
+ mergeAdapter.notifyDataSetChanged();
+ }
+
+ private void expandSongs() {
+ songAdapter.clear();
+ for (MusicDirectory.Entry song : searchResult.getSongs()) {
+ songAdapter.add(song);
+ }
+ songAdapter.notifyDataSetChanged();
+ mergeAdapter.removeAdapter(moreSongsAdapter);
+ mergeAdapter.notifyDataSetChanged();
+ }
+
+ private void onArtistSelected(Artist artist) {
+ Intent intent = new Intent(this, SelectAlbumActivity.class);
+ intent.putExtra(Constants.INTENT_EXTRA_NAME_ID, artist.getId());
+ intent.putExtra(Constants.INTENT_EXTRA_NAME_NAME, artist.getName());
+ Util.startActivityWithoutTransition(this, intent);
+ }
+
+ private void onAlbumSelected(MusicDirectory.Entry album, boolean autoplay) {
+ Intent intent = new Intent(SearchActivity.this, SelectAlbumActivity.class);
+ intent.putExtra(Constants.INTENT_EXTRA_NAME_ID, album.getId());
+ intent.putExtra(Constants.INTENT_EXTRA_NAME_NAME, album.getTitle());
+ intent.putExtra(Constants.INTENT_EXTRA_NAME_AUTOPLAY, autoplay);
+ Util.startActivityWithoutTransition(SearchActivity.this, intent);
+ }
+
+ private void onSongSelected(MusicDirectory.Entry song, boolean save, boolean append, boolean autoplay, boolean playNext) {
+ DownloadService downloadService = getDownloadService();
+ if (downloadService != null) {
+ if (!append) {
+ downloadService.clear();
+ }
+ downloadService.download(Arrays.asList(song), save, false, playNext);
+ if (autoplay) {
+ downloadService.play(downloadService.size() - 1);
+ }
+
+ Util.toast(SearchActivity.this, getResources().getQuantityString(R.plurals.select_album_n_songs_added, 1, 1));
+ }
+ }
+
+ private void onVideoSelected(MusicDirectory.Entry entry) {
+ Intent intent = new Intent(Intent.ACTION_VIEW);
+ intent.setData(Uri.parse(MusicServiceFactory.getMusicService(this).getVideoUrl(this, entry.getId())));
+ startActivity(intent);
+ }
+
+ private void autoplay() {
+ if (!searchResult.getSongs().isEmpty()) {
+ onSongSelected(searchResult.getSongs().get(0), false, false, true, false);
+ } else if (!searchResult.getAlbums().isEmpty()) {
+ onAlbumSelected(searchResult.getAlbums().get(0), true);
+ }
+ }
+} \ No newline at end of file
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/SelectAlbumActivity.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/SelectAlbumActivity.java
new file mode 100644
index 00000000..b354599f
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/SelectAlbumActivity.java
@@ -0,0 +1,568 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.activity;
+
+import android.app.AlertDialog;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.util.Log;
+import android.view.ContextMenu;
+import android.view.LayoutInflater;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.AdapterView;
+import android.widget.Button;
+import android.widget.ImageButton;
+import android.widget.ImageView;
+import android.widget.ListView;
+import net.sourceforge.subsonic.androidapp.R;
+import net.sourceforge.subsonic.androidapp.domain.MusicDirectory;
+import net.sourceforge.subsonic.androidapp.service.DownloadFile;
+import net.sourceforge.subsonic.androidapp.service.MusicService;
+import net.sourceforge.subsonic.androidapp.service.MusicServiceFactory;
+import net.sourceforge.subsonic.androidapp.util.Constants;
+import net.sourceforge.subsonic.androidapp.util.EntryAdapter;
+import net.sourceforge.subsonic.androidapp.util.Pair;
+import net.sourceforge.subsonic.androidapp.util.TabActivityBackgroundTask;
+import net.sourceforge.subsonic.androidapp.util.Util;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class SelectAlbumActivity extends SubsonicTabActivity {
+
+ private static final String TAG = SelectAlbumActivity.class.getSimpleName();
+
+ private ListView entryList;
+ private View footer;
+ private View emptyView;
+ private Button selectButton;
+ private Button playNowButton;
+ private Button playLastButton;
+ private Button pinButton;
+ private Button unpinButton;
+ private Button deleteButton;
+ private Button moreButton;
+ private ImageView coverArtView;
+ private boolean licenseValid;
+ private ImageButton playAllButton;
+
+ /**
+ * Called when the activity is first created.
+ */
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.select_album);
+
+ entryList = (ListView) findViewById(R.id.select_album_entries);
+
+ footer = LayoutInflater.from(this).inflate(R.layout.select_album_footer, entryList, false);
+ entryList.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE);
+ entryList.setOnItemClickListener(new AdapterView.OnItemClickListener() {
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ if (position >= 0) {
+ MusicDirectory.Entry entry = (MusicDirectory.Entry) parent.getItemAtPosition(position);
+ if (entry.isDirectory()) {
+ Intent intent = new Intent(SelectAlbumActivity.this, SelectAlbumActivity.class);
+ intent.putExtra(Constants.INTENT_EXTRA_NAME_ID, entry.getId());
+ intent.putExtra(Constants.INTENT_EXTRA_NAME_NAME, entry.getTitle());
+ Util.startActivityWithoutTransition(SelectAlbumActivity.this, intent);
+ } else if (entry.isVideo()) {
+ playVideo(entry);
+ } else {
+ enableButtons();
+ }
+ }
+ }
+ });
+
+ coverArtView = (ImageView) findViewById(R.id.actionbar_home_icon);
+ selectButton = (Button) findViewById(R.id.select_album_select);
+ playNowButton = (Button) findViewById(R.id.select_album_play_now);
+ playLastButton = (Button) findViewById(R.id.select_album_play_last);
+ pinButton = (Button) footer.findViewById(R.id.select_album_pin);
+ unpinButton = (Button) footer.findViewById(R.id.select_album_unpin);
+ unpinButton = (Button) footer.findViewById(R.id.select_album_unpin);
+ deleteButton = (Button) footer.findViewById(R.id.select_album_delete);
+ moreButton = (Button) footer.findViewById(R.id.select_album_more);
+ emptyView = findViewById(R.id.select_album_empty);
+
+ selectButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ selectAllOrNone();
+ }
+ });
+ playNowButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ download(false, false, true, false);
+ selectAll(false, false);
+ }
+ });
+ playLastButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ download(true, false, false, false);
+ selectAll(false, false);
+ }
+ });
+ pinButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ download(true, true, false, false);
+ selectAll(false, false);
+ }
+ });
+ unpinButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ unpin();
+ selectAll(false, false);
+ }
+ });
+ deleteButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ delete();
+ selectAll(false, false);
+ }
+ });
+
+ registerForContextMenu(entryList);
+
+ enableButtons();
+
+ String id = getIntent().getStringExtra(Constants.INTENT_EXTRA_NAME_ID);
+ String name = getIntent().getStringExtra(Constants.INTENT_EXTRA_NAME_NAME);
+ String playlistId = getIntent().getStringExtra(Constants.INTENT_EXTRA_NAME_PLAYLIST_ID);
+ String playlistName = getIntent().getStringExtra(Constants.INTENT_EXTRA_NAME_PLAYLIST_NAME);
+ String albumListType = getIntent().getStringExtra(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TYPE);
+ int albumListSize = getIntent().getIntExtra(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_SIZE, 0);
+ int albumListOffset = getIntent().getIntExtra(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_OFFSET, 0);
+
+ if (playlistId != null) {
+ getPlaylist(playlistId, playlistName);
+ } else if (albumListType != null) {
+ getAlbumList(albumListType, albumListSize, albumListOffset);
+ } else {
+ getMusicDirectory(id, name);
+ }
+
+ // Button 1: play all
+ playAllButton = (ImageButton) findViewById(R.id.action_button_1);
+ playAllButton.setImageResource(R.drawable.action_play_all);
+ playAllButton.setVisibility(View.GONE);
+ playAllButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ playAll();
+ }
+ });
+
+ // Button 2: refresh
+ ImageButton refreshButton = (ImageButton) findViewById(R.id.action_button_2);
+ refreshButton.setImageResource(R.drawable.action_refresh);
+ refreshButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ refresh();
+ }
+ });
+ }
+
+ private void playAll() {
+ boolean hasSubFolders = false;
+ for (int i = 0; i < entryList.getCount(); i++) {
+ MusicDirectory.Entry entry = (MusicDirectory.Entry) entryList.getItemAtPosition(i);
+ if (entry != null && entry.isDirectory()) {
+ hasSubFolders = true;
+ break;
+ }
+ }
+
+ String id = getIntent().getStringExtra(Constants.INTENT_EXTRA_NAME_ID);
+ if (hasSubFolders && id != null) {
+ downloadRecursively(id, false, false, true);
+ } else {
+ selectAll(true, false);
+ download(false, false, true, false);
+ selectAll(false, false);
+ }
+ }
+
+ private void refresh() {
+ finish();
+ Intent intent = getIntent();
+ intent.putExtra(Constants.INTENT_EXTRA_NAME_REFRESH, true);
+ Util.startActivityWithoutTransition(this, intent);
+ }
+
+ @Override
+ public void onCreateContextMenu(ContextMenu menu, View view, ContextMenu.ContextMenuInfo menuInfo) {
+ super.onCreateContextMenu(menu, view, menuInfo);
+ AdapterView.AdapterContextMenuInfo info =
+ (AdapterView.AdapterContextMenuInfo) menuInfo;
+
+ MusicDirectory.Entry entry = (MusicDirectory.Entry) entryList.getItemAtPosition(info.position);
+
+ if (entry.isDirectory()) {
+ MenuInflater inflater = getMenuInflater();
+ inflater.inflate(R.menu.select_album_context, menu);
+ } else {
+ MenuInflater inflater = getMenuInflater();
+ inflater.inflate(R.menu.select_song_context, menu);
+ }
+ }
+
+ @Override
+ public boolean onContextItemSelected(MenuItem menuItem) {
+ AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuItem.getMenuInfo();
+ MusicDirectory.Entry entry = (MusicDirectory.Entry) entryList.getItemAtPosition(info.position);
+ List<MusicDirectory.Entry> songs = new ArrayList<MusicDirectory.Entry>(10);
+ songs.add((MusicDirectory.Entry) entryList.getItemAtPosition(info.position));
+ switch (menuItem.getItemId()) {
+ case R.id.album_menu_play_now:
+ downloadRecursively(entry.getId(), false, false, true);
+ break;
+ case R.id.album_menu_play_last:
+ downloadRecursively(entry.getId(), false, true, false);
+ break;
+ case R.id.album_menu_pin:
+ downloadRecursively(entry.getId(), true, true, false);
+ break;
+ case R.id.song_menu_play_now:
+ getDownloadService().download(songs, false, true, true);
+ break;
+ case R.id.song_menu_play_next:
+ getDownloadService().download(songs, false, false, true);
+ break;
+ case R.id.song_menu_play_last:
+ getDownloadService().download(songs, false, false, false);
+ break;
+ default:
+ return super.onContextItemSelected(menuItem);
+ }
+ return true;
+ }
+
+ private void getMusicDirectory(final String id, String name) {
+ setTitle(name);
+
+ new LoadTask() {
+ @Override
+ protected MusicDirectory load(MusicService service) throws Exception {
+ boolean refresh = getIntent().getBooleanExtra(Constants.INTENT_EXTRA_NAME_REFRESH, false);
+ return service.getMusicDirectory(id, refresh, SelectAlbumActivity.this, this);
+ }
+ }.execute();
+ }
+
+ private void getPlaylist(final String playlistId, String playlistName) {
+ setTitle(playlistName);
+
+ new LoadTask() {
+ @Override
+ protected MusicDirectory load(MusicService service) throws Exception {
+ return service.getPlaylist(playlistId, SelectAlbumActivity.this, this);
+ }
+ }.execute();
+ }
+
+ private void getAlbumList(final String albumListType, final int size, final int offset) {
+
+ if ("newest".equals(albumListType)) {
+ setTitle(R.string.main_albums_newest);
+ } else if ("random".equals(albumListType)) {
+ setTitle(R.string.main_albums_random);
+ } else if ("highest".equals(albumListType)) {
+ setTitle(R.string.main_albums_highest);
+ } else if ("recent".equals(albumListType)) {
+ setTitle(R.string.main_albums_recent);
+ } else if ("frequent".equals(albumListType)) {
+ setTitle(R.string.main_albums_frequent);
+ }
+
+ new LoadTask() {
+ @Override
+ protected MusicDirectory load(MusicService service) throws Exception {
+ return service.getAlbumList(albumListType, size, offset, SelectAlbumActivity.this, this);
+ }
+
+ @Override
+ protected void done(Pair<MusicDirectory, Boolean> result) {
+ if (!result.getFirst().getChildren().isEmpty()) {
+ pinButton.setVisibility(View.GONE);
+ unpinButton.setVisibility(View.GONE);
+ deleteButton.setVisibility(View.GONE);
+ moreButton.setVisibility(View.VISIBLE);
+ entryList.addFooterView(footer);
+
+ moreButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ Intent intent = new Intent(SelectAlbumActivity.this, SelectAlbumActivity.class);
+ String type = getIntent().getStringExtra(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TYPE);
+ int size = getIntent().getIntExtra(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_SIZE, 0);
+ int offset = getIntent().getIntExtra(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_OFFSET, 0) + size;
+
+ intent.putExtra(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TYPE, type);
+ intent.putExtra(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_SIZE, size);
+ intent.putExtra(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_OFFSET, offset);
+ Util.startActivityWithoutTransition(SelectAlbumActivity.this, intent);
+ }
+ });
+ }
+ super.done(result);
+ }
+ }.execute();
+ }
+
+ private void selectAllOrNone() {
+ boolean someUnselected = false;
+ int count = entryList.getCount();
+ for (int i = 0; i < count; i++) {
+ if (!entryList.isItemChecked(i) && entryList.getItemAtPosition(i) instanceof MusicDirectory.Entry) {
+ someUnselected = true;
+ break;
+ }
+ }
+ selectAll(someUnselected, true);
+ }
+
+ private void selectAll(boolean selected, boolean toast) {
+ int count = entryList.getCount();
+ int selectedCount = 0;
+ for (int i = 0; i < count; i++) {
+ MusicDirectory.Entry entry = (MusicDirectory.Entry) entryList.getItemAtPosition(i);
+ if (entry != null && !entry.isDirectory() && !entry.isVideo()) {
+ entryList.setItemChecked(i, selected);
+ selectedCount++;
+ }
+ }
+
+ // Display toast: N tracks selected / N tracks unselected
+ if (toast) {
+ int toastResId = selected ? R.string.select_album_n_selected
+ : R.string.select_album_n_unselected;
+ Util.toast(this, getString(toastResId, selectedCount));
+ }
+
+ enableButtons();
+ }
+
+ private void enableButtons() {
+ if (getDownloadService() == null) {
+ return;
+ }
+
+ List<MusicDirectory.Entry> selection = getSelectedSongs();
+ boolean enabled = !selection.isEmpty();
+ boolean unpinEnabled = false;
+ boolean deleteEnabled = false;
+
+ for (MusicDirectory.Entry song : selection) {
+ DownloadFile downloadFile = getDownloadService().forSong(song);
+ if (downloadFile.isCompleteFileAvailable()) {
+ deleteEnabled = true;
+ }
+ if (downloadFile.isSaved()) {
+ unpinEnabled = true;
+ }
+ }
+
+ playNowButton.setEnabled(enabled);
+ playLastButton.setEnabled(enabled);
+ pinButton.setEnabled(enabled && !Util.isOffline(this));
+ unpinButton.setEnabled(unpinEnabled);
+ deleteButton.setEnabled(deleteEnabled);
+ }
+
+ private List<MusicDirectory.Entry> getSelectedSongs() {
+ List<MusicDirectory.Entry> songs = new ArrayList<MusicDirectory.Entry>(10);
+ int count = entryList.getCount();
+ for (int i = 0; i < count; i++) {
+ if (entryList.isItemChecked(i)) {
+ songs.add((MusicDirectory.Entry) entryList.getItemAtPosition(i));
+ }
+ }
+ return songs;
+ }
+
+ private void download(final boolean append, final boolean save, final boolean autoplay, final boolean playNext) {
+ if (getDownloadService() == null) {
+ return;
+ }
+
+ final List<MusicDirectory.Entry> songs = getSelectedSongs();
+ Runnable onValid = new Runnable() {
+ @Override
+ public void run() {
+ if (!append) {
+ getDownloadService().clear();
+ }
+
+ warnIfNetworkOrStorageUnavailable();
+ getDownloadService().download(songs, save, autoplay, playNext);
+ String playlistName = getIntent().getStringExtra(Constants.INTENT_EXTRA_NAME_PLAYLIST_NAME);
+ if (playlistName != null) {
+ getDownloadService().setSuggestedPlaylistName(playlistName);
+ }
+ if (autoplay) {
+ Util.startActivityWithoutTransition(SelectAlbumActivity.this, DownloadActivity.class);
+ } else if (save) {
+ Util.toast(SelectAlbumActivity.this,
+ getResources().getQuantityString(R.plurals.select_album_n_songs_downloading, songs.size(), songs.size()));
+ } else if (append) {
+ Util.toast(SelectAlbumActivity.this,
+ getResources().getQuantityString(R.plurals.select_album_n_songs_added, songs.size(), songs.size()));
+ }
+ }
+ };
+
+ checkLicenseAndTrialPeriod(onValid);
+ }
+
+ private void delete() {
+ if (getDownloadService() != null) {
+ getDownloadService().delete(getSelectedSongs());
+ }
+ }
+
+ private void unpin() {
+ if (getDownloadService() != null) {
+ getDownloadService().unpin(getSelectedSongs());
+ }
+ }
+
+ private void playVideo(MusicDirectory.Entry entry) {
+ Intent intent = new Intent(Intent.ACTION_VIEW);
+ intent.setData(Uri.parse(MusicServiceFactory.getMusicService(this).getVideoUrl(this, entry.getId())));
+
+ startActivity(intent);
+ }
+
+ private void checkLicenseAndTrialPeriod(Runnable onValid) {
+ if (licenseValid) {
+ onValid.run();
+ return;
+ }
+
+ int trialDaysLeft = Util.getRemainingTrialDays(this);
+ Log.i(TAG, trialDaysLeft + " trial days left.");
+
+ if (trialDaysLeft == 0) {
+ showDonationDialog(trialDaysLeft, null);
+ } else if (trialDaysLeft < Constants.FREE_TRIAL_DAYS / 2) {
+ showDonationDialog(trialDaysLeft, onValid);
+ } else {
+ Util.toast(this, getResources().getString(R.string.select_album_not_licensed, trialDaysLeft));
+ onValid.run();
+ }
+ }
+
+ private void showDonationDialog(int trialDaysLeft, final Runnable onValid) {
+ AlertDialog.Builder builder = new AlertDialog.Builder(this);
+ builder.setIcon(android.R.drawable.ic_dialog_info);
+
+ if (trialDaysLeft == 0) {
+ builder.setTitle(R.string.select_album_donate_dialog_0_trial_days_left);
+ } else {
+ builder.setTitle(getResources().getQuantityString(R.plurals.select_album_donate_dialog_n_trial_days_left,
+ trialDaysLeft, trialDaysLeft));
+ }
+
+ builder.setMessage(R.string.select_album_donate_dialog_message);
+
+ builder.setPositiveButton(R.string.select_album_donate_dialog_now,
+ new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialogInterface, int i) {
+ startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(Constants.DONATION_URL)));
+ }
+ });
+
+ builder.setNegativeButton(R.string.select_album_donate_dialog_later,
+ new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialogInterface, int i) {
+ dialogInterface.dismiss();
+ if (onValid != null) {
+ onValid.run();
+ }
+ }
+ });
+
+ builder.create().show();
+ }
+
+ private abstract class LoadTask extends TabActivityBackgroundTask<Pair<MusicDirectory, Boolean>> {
+
+ public LoadTask() {
+ super(SelectAlbumActivity.this);
+ }
+
+ protected abstract MusicDirectory load(MusicService service) throws Exception;
+
+ @Override
+ protected Pair<MusicDirectory, Boolean> doInBackground() throws Throwable {
+ MusicService musicService = MusicServiceFactory.getMusicService(SelectAlbumActivity.this);
+ MusicDirectory dir = load(musicService);
+ boolean valid = musicService.isLicenseValid(SelectAlbumActivity.this, this);
+ return new Pair<MusicDirectory, Boolean>(dir, valid);
+ }
+
+ @Override
+ protected void done(Pair<MusicDirectory, Boolean> result) {
+ List<MusicDirectory.Entry> entries = result.getFirst().getChildren();
+
+ int songCount = 0;
+ for (MusicDirectory.Entry entry : entries) {
+ if (!entry.isDirectory()) {
+ songCount++;
+ }
+ }
+
+ if (songCount > 0) {
+ getImageLoader().loadImage(coverArtView, entries.get(0), false, true);
+ entryList.addFooterView(footer);
+ selectButton.setVisibility(View.VISIBLE);
+ playNowButton.setVisibility(View.VISIBLE);
+ playLastButton.setVisibility(View.VISIBLE);
+ }
+
+ boolean isAlbumList = getIntent().hasExtra(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TYPE);
+
+ emptyView.setVisibility(entries.isEmpty() ? View.VISIBLE : View.GONE);
+ playAllButton.setVisibility(isAlbumList || entries.isEmpty() ? View.GONE : View.VISIBLE);
+ entryList.setAdapter(new EntryAdapter(SelectAlbumActivity.this, getImageLoader(), entries, true));
+ licenseValid = result.getSecond();
+
+ boolean playAll = getIntent().getBooleanExtra(Constants.INTENT_EXTRA_NAME_AUTOPLAY, false);
+ if (playAll && songCount > 0) {
+ playAll();
+ }
+ }
+ }
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/SelectArtistActivity.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/SelectArtistActivity.java
new file mode 100644
index 00000000..959066ab
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/SelectArtistActivity.java
@@ -0,0 +1,228 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+
+package net.sourceforge.subsonic.androidapp.activity;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.ContextMenu;
+import android.view.LayoutInflater;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.AdapterView;
+import android.widget.ImageButton;
+import android.widget.ListView;
+import android.widget.TextView;
+import net.sourceforge.subsonic.androidapp.R;
+import net.sourceforge.subsonic.androidapp.domain.Artist;
+import net.sourceforge.subsonic.androidapp.domain.Indexes;
+import net.sourceforge.subsonic.androidapp.domain.MusicFolder;
+import net.sourceforge.subsonic.androidapp.service.MusicService;
+import net.sourceforge.subsonic.androidapp.service.MusicServiceFactory;
+import net.sourceforge.subsonic.androidapp.util.ArtistAdapter;
+import net.sourceforge.subsonic.androidapp.util.BackgroundTask;
+import net.sourceforge.subsonic.androidapp.util.Constants;
+import net.sourceforge.subsonic.androidapp.util.TabActivityBackgroundTask;
+import net.sourceforge.subsonic.androidapp.util.Util;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class SelectArtistActivity extends SubsonicTabActivity implements AdapterView.OnItemClickListener {
+
+ private static final int MENU_GROUP_MUSIC_FOLDER = 10;
+
+ private ListView artistList;
+ private View folderButton;
+ private TextView folderName;
+ private List<MusicFolder> musicFolders;
+
+ /**
+ * Called when the activity is first created.
+ */
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.select_artist);
+
+ artistList = (ListView) findViewById(R.id.select_artist_list);
+ artistList.setOnItemClickListener(this);
+
+ folderButton = LayoutInflater.from(this).inflate(R.layout.select_artist_header, artistList, false);
+ folderName = (TextView) folderButton.findViewById(R.id.select_artist_folder_2);
+
+ if (!Util.isOffline(this)) {
+ artistList.addHeaderView(folderButton);
+ }
+
+ registerForContextMenu(artistList);
+
+ setTitle(Util.isOffline(this) ? R.string.music_library_label_offline : R.string.music_library_label);
+
+ // Button 1: shuffle
+ ImageButton shuffleButton = (ImageButton) findViewById(R.id.action_button_1);
+ shuffleButton.setImageResource(R.drawable.action_shuffle);
+ shuffleButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ Intent intent = new Intent(SelectArtistActivity.this, DownloadActivity.class);
+ intent.putExtra(Constants.INTENT_EXTRA_NAME_SHUFFLE, true);
+ Util.startActivityWithoutTransition(SelectArtistActivity.this, intent);
+ }
+ });
+
+ // Button 2: refresh
+ ImageButton refreshButton = (ImageButton) findViewById(R.id.action_button_2);
+ refreshButton.setImageResource(R.drawable.action_refresh);
+ refreshButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ refresh();
+ }
+ });
+
+ musicFolders = null;
+ load();
+ }
+
+ private void refresh() {
+ finish();
+ Intent intent = getIntent();
+ intent.putExtra(Constants.INTENT_EXTRA_NAME_REFRESH, true);
+ Util.startActivityWithoutTransition(this, intent);
+ }
+
+ private void selectFolder() {
+ folderButton.showContextMenu();
+ }
+
+ private void load() {
+ BackgroundTask<Indexes> task = new TabActivityBackgroundTask<Indexes>(this) {
+ @Override
+ protected Indexes doInBackground() throws Throwable {
+ boolean refresh = getIntent().getBooleanExtra(Constants.INTENT_EXTRA_NAME_REFRESH, false);
+ MusicService musicService = MusicServiceFactory.getMusicService(SelectArtistActivity.this);
+ if (!Util.isOffline(SelectArtistActivity.this)) {
+ musicFolders = musicService.getMusicFolders(refresh, SelectArtistActivity.this, this);
+ }
+ String musicFolderId = Util.getSelectedMusicFolderId(SelectArtistActivity.this);
+ return musicService.getIndexes(musicFolderId, refresh, SelectArtistActivity.this, this);
+ }
+
+ @Override
+ protected void done(Indexes result) {
+ List<Artist> artists = new ArrayList<Artist>(result.getShortcuts().size() + result.getArtists().size());
+ artists.addAll(result.getShortcuts());
+ artists.addAll(result.getArtists());
+ artistList.setAdapter(new ArtistAdapter(SelectArtistActivity.this, artists));
+
+ // Display selected music folder
+ if (musicFolders != null) {
+ String musicFolderId = Util.getSelectedMusicFolderId(SelectArtistActivity.this);
+ if (musicFolderId == null) {
+ folderName.setText(R.string.select_artist_all_folders);
+ } else {
+ for (MusicFolder musicFolder : musicFolders) {
+ if (musicFolder.getId().equals(musicFolderId)) {
+ folderName.setText(musicFolder.getName());
+ break;
+ }
+ }
+ }
+ }
+ }
+ };
+ task.execute();
+ }
+
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ if (view == folderButton) {
+ selectFolder();
+ } else {
+ Artist artist = (Artist) parent.getItemAtPosition(position);
+ Intent intent = new Intent(this, SelectAlbumActivity.class);
+ intent.putExtra(Constants.INTENT_EXTRA_NAME_ID, artist.getId());
+ intent.putExtra(Constants.INTENT_EXTRA_NAME_NAME, artist.getName());
+ Util.startActivityWithoutTransition(this, intent);
+ }
+ }
+
+ @Override
+ public void onCreateContextMenu(ContextMenu menu, View view, ContextMenu.ContextMenuInfo menuInfo) {
+ super.onCreateContextMenu(menu, view, menuInfo);
+
+ AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuInfo;
+
+ if (artistList.getItemAtPosition(info.position) instanceof Artist) {
+ MenuInflater inflater = getMenuInflater();
+ inflater.inflate(R.menu.select_artist_context, menu);
+ } else if (info.position == 0) {
+ String musicFolderId = Util.getSelectedMusicFolderId(this);
+ MenuItem menuItem = menu.add(MENU_GROUP_MUSIC_FOLDER, -1, 0, R.string.select_artist_all_folders);
+ if (musicFolderId == null) {
+ menuItem.setChecked(true);
+ }
+ if (musicFolders != null) {
+ for (int i = 0; i < musicFolders.size(); i++) {
+ MusicFolder musicFolder = musicFolders.get(i);
+ menuItem = menu.add(MENU_GROUP_MUSIC_FOLDER, i, i + 1, musicFolder.getName());
+ if (musicFolder.getId().equals(musicFolderId)) {
+ menuItem.setChecked(true);
+ }
+ }
+ }
+ menu.setGroupCheckable(MENU_GROUP_MUSIC_FOLDER, true, true);
+ }
+ }
+
+ @Override
+ public boolean onContextItemSelected(MenuItem menuItem) {
+ AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuItem.getMenuInfo();
+
+ Artist artist = (Artist) artistList.getItemAtPosition(info.position);
+
+ if (artist != null) {
+ switch (menuItem.getItemId()) {
+ case R.id.artist_menu_play_now:
+ downloadRecursively(artist.getId(), false, false, true);
+ break;
+ case R.id.artist_menu_play_last:
+ downloadRecursively(artist.getId(), false, true, false);
+ break;
+ case R.id.artist_menu_pin:
+ downloadRecursively(artist.getId(), true, true, false);
+ break;
+ default:
+ return super.onContextItemSelected(menuItem);
+ }
+ } else if (info.position == 0) {
+ MusicFolder selectedFolder = menuItem.getItemId() == -1 ? null : musicFolders.get(menuItem.getItemId());
+ String musicFolderId = selectedFolder == null ? null : selectedFolder.getId();
+ String musicFolderName = selectedFolder == null ? getString(R.string.select_artist_all_folders)
+ : selectedFolder.getName();
+ Util.setSelectedMusicFolderId(this, musicFolderId);
+ folderName.setText(musicFolderName);
+ refresh();
+ }
+
+ return true;
+ }
+} \ No newline at end of file
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/SelectPlaylistActivity.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/SelectPlaylistActivity.java
new file mode 100644
index 00000000..253124b5
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/SelectPlaylistActivity.java
@@ -0,0 +1,141 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+
+package net.sourceforge.subsonic.androidapp.activity;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.ContextMenu;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.AdapterView;
+import android.widget.ImageButton;
+import android.widget.ListView;
+import net.sourceforge.subsonic.androidapp.R;
+import net.sourceforge.subsonic.androidapp.domain.Playlist;
+import net.sourceforge.subsonic.androidapp.service.MusicServiceFactory;
+import net.sourceforge.subsonic.androidapp.service.MusicService;
+import net.sourceforge.subsonic.androidapp.util.BackgroundTask;
+import net.sourceforge.subsonic.androidapp.util.Constants;
+import net.sourceforge.subsonic.androidapp.util.PlaylistAdapter;
+import net.sourceforge.subsonic.androidapp.util.TabActivityBackgroundTask;
+import net.sourceforge.subsonic.androidapp.util.Util;
+
+import java.util.List;
+
+public class SelectPlaylistActivity extends SubsonicTabActivity implements AdapterView.OnItemClickListener {
+
+ private static final int MENU_ITEM_PLAY_ALL = 1;
+
+ private ListView list;
+ private View emptyTextView;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.select_playlist);
+
+ list = (ListView) findViewById(R.id.select_playlist_list);
+ emptyTextView = findViewById(R.id.select_playlist_empty);
+ list.setOnItemClickListener(this);
+ registerForContextMenu(list);
+
+ // Title: Playlists
+ setTitle(R.string.playlist_label);
+
+ // Button 1: gone
+ ImageButton searchButton = (ImageButton)findViewById(R.id.action_button_1);
+ searchButton.setVisibility(View.GONE);
+
+ // Button 2: refresh
+ ImageButton refreshButton = (ImageButton) findViewById(R.id.action_button_2);
+ refreshButton.setImageResource(R.drawable.action_refresh);
+ refreshButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ refresh();
+ }
+ });
+
+ load();
+ }
+
+ private void refresh() {
+ finish();
+ Intent intent = new Intent(this, SelectPlaylistActivity.class);
+ intent.putExtra(Constants.INTENT_EXTRA_NAME_REFRESH, true);
+ Util.startActivityWithoutTransition(this, intent);
+ }
+
+ private void load() {
+ BackgroundTask<List<Playlist>> task = new TabActivityBackgroundTask<List<Playlist>>(this) {
+ @Override
+ protected List<Playlist> doInBackground() throws Throwable {
+ MusicService musicService = MusicServiceFactory.getMusicService(SelectPlaylistActivity.this);
+ boolean refresh = getIntent().getBooleanExtra(Constants.INTENT_EXTRA_NAME_REFRESH, false);
+ return musicService.getPlaylists(refresh, SelectPlaylistActivity.this, this);
+ }
+
+ @Override
+ protected void done(List<Playlist> result) {
+ list.setAdapter(new PlaylistAdapter(SelectPlaylistActivity.this, PlaylistAdapter.PlaylistComparator.sort(result)));
+ emptyTextView.setVisibility(result.isEmpty() ? View.VISIBLE : View.GONE);
+ }
+ };
+ task.execute();
+ }
+
+ @Override
+ public void onCreateContextMenu(ContextMenu menu, View view, ContextMenu.ContextMenuInfo menuInfo) {
+ super.onCreateContextMenu(menu, view, menuInfo);
+ menu.add(Menu.NONE, MENU_ITEM_PLAY_ALL, MENU_ITEM_PLAY_ALL, R.string.common_play_now);
+ }
+
+ @Override
+ public boolean onContextItemSelected(MenuItem menuItem) {
+ AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuItem.getMenuInfo();
+ Playlist playlist = (Playlist) list.getItemAtPosition(info.position);
+
+ switch (menuItem.getItemId()) {
+ case MENU_ITEM_PLAY_ALL:
+ Intent intent = new Intent(SelectPlaylistActivity.this, SelectAlbumActivity.class);
+ intent.putExtra(Constants.INTENT_EXTRA_NAME_PLAYLIST_ID, playlist.getId());
+ intent.putExtra(Constants.INTENT_EXTRA_NAME_PLAYLIST_NAME, playlist.getName());
+ intent.putExtra(Constants.INTENT_EXTRA_NAME_AUTOPLAY, true);
+ Util.startActivityWithoutTransition(SelectPlaylistActivity.this, intent);
+ break;
+ default:
+ return super.onContextItemSelected(menuItem);
+ }
+ return true;
+ }
+
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+
+ Playlist playlist = (Playlist) parent.getItemAtPosition(position);
+
+ Intent intent = new Intent(SelectPlaylistActivity.this, SelectAlbumActivity.class);
+ intent.putExtra(Constants.INTENT_EXTRA_NAME_PLAYLIST_ID, playlist.getId());
+ intent.putExtra(Constants.INTENT_EXTRA_NAME_PLAYLIST_NAME, playlist.getName());
+ Util.startActivityWithoutTransition(SelectPlaylistActivity.this, intent);
+ }
+
+} \ No newline at end of file
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/SettingsActivity.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/SettingsActivity.java
new file mode 100644
index 00000000..f726a2af
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/SettingsActivity.java
@@ -0,0 +1,297 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.activity;
+
+import android.content.SharedPreferences;
+import android.os.Bundle;
+import android.preference.EditTextPreference;
+import android.preference.ListPreference;
+import android.preference.Preference;
+import android.preference.PreferenceActivity;
+import android.preference.PreferenceScreen;
+import android.provider.SearchRecentSuggestions;
+import android.util.Log;
+import net.sourceforge.subsonic.androidapp.R;
+import net.sourceforge.subsonic.androidapp.provider.SearchSuggestionProvider;
+import net.sourceforge.subsonic.androidapp.service.DownloadService;
+import net.sourceforge.subsonic.androidapp.service.DownloadServiceImpl;
+import net.sourceforge.subsonic.androidapp.service.MusicService;
+import net.sourceforge.subsonic.androidapp.service.MusicServiceFactory;
+import net.sourceforge.subsonic.androidapp.util.Constants;
+import net.sourceforge.subsonic.androidapp.util.ErrorDialog;
+import net.sourceforge.subsonic.androidapp.util.FileUtil;
+import net.sourceforge.subsonic.androidapp.util.ModalBackgroundTask;
+import net.sourceforge.subsonic.androidapp.util.Util;
+
+import java.io.File;
+import java.net.URL;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+public class SettingsActivity extends PreferenceActivity implements SharedPreferences.OnSharedPreferenceChangeListener {
+
+ private static final String TAG = SettingsActivity.class.getSimpleName();
+ private final Map<String, ServerSettings> serverSettings = new LinkedHashMap<String, ServerSettings>();
+ private boolean testingConnection;
+ private ListPreference theme;
+ private ListPreference maxBitrateWifi;
+ private ListPreference maxBitrateMobile;
+ private ListPreference cacheSize;
+ private EditTextPreference cacheLocation;
+ private ListPreference preloadCount;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ addPreferencesFromResource(R.xml.settings);
+
+ theme = (ListPreference) findPreference(Constants.PREFERENCES_KEY_THEME);
+ maxBitrateWifi = (ListPreference) findPreference(Constants.PREFERENCES_KEY_MAX_BITRATE_WIFI);
+ maxBitrateMobile = (ListPreference) findPreference(Constants.PREFERENCES_KEY_MAX_BITRATE_MOBILE);
+ cacheSize = (ListPreference) findPreference(Constants.PREFERENCES_KEY_CACHE_SIZE);
+ cacheLocation = (EditTextPreference) findPreference(Constants.PREFERENCES_KEY_CACHE_LOCATION);
+ preloadCount = (ListPreference) findPreference(Constants.PREFERENCES_KEY_PRELOAD_COUNT);
+
+ findPreference("testConnection1").setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() {
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ testConnection(1);
+ return false;
+ }
+ });
+
+ findPreference("testConnection2").setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() {
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ testConnection(2);
+ return false;
+ }
+ });
+
+ findPreference("testConnection3").setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() {
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ testConnection(3);
+ return false;
+ }
+ });
+
+ findPreference("clearSearchHistory").setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() {
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ SearchRecentSuggestions suggestions = new SearchRecentSuggestions(SettingsActivity.this, SearchSuggestionProvider.AUTHORITY, SearchSuggestionProvider.MODE);
+ suggestions.clearHistory();
+ Util.toast(SettingsActivity.this, R.string.settings_search_history_cleared);
+ return false;
+ }
+ });
+
+ for (int i = 1; i <= 3; i++) {
+ String instance = String.valueOf(i);
+ serverSettings.put(instance, new ServerSettings(instance));
+ }
+
+ SharedPreferences prefs = Util.getPreferences(this);
+ prefs.registerOnSharedPreferenceChangeListener(this);
+
+ update();
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+
+ SharedPreferences prefs = Util.getPreferences(this);
+ prefs.unregisterOnSharedPreferenceChangeListener(this);
+ }
+
+ @Override
+ public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
+ Log.d(TAG, "Preference changed: " + key);
+ update();
+
+ if (Constants.PREFERENCES_KEY_HIDE_MEDIA.equals(key)) {
+ setHideMedia(sharedPreferences.getBoolean(key, false));
+ }
+ else if (Constants.PREFERENCES_KEY_MEDIA_BUTTONS.equals(key)) {
+ setMediaButtonsEnabled(sharedPreferences.getBoolean(key, true));
+ }
+ else if (Constants.PREFERENCES_KEY_CACHE_LOCATION.equals(key)) {
+ setCacheLocation(sharedPreferences.getString(key, ""));
+ }
+ }
+
+ private void update() {
+ if (testingConnection) {
+ return;
+ }
+
+ theme.setSummary(theme.getEntry());
+ maxBitrateWifi.setSummary(maxBitrateWifi.getEntry());
+ maxBitrateMobile.setSummary(maxBitrateMobile.getEntry());
+ cacheSize.setSummary(cacheSize.getEntry());
+ cacheLocation.setSummary(cacheLocation.getText());
+ preloadCount.setSummary(preloadCount.getEntry());
+ for (ServerSettings ss : serverSettings.values()) {
+ ss.update();
+ }
+ }
+
+ private void setHideMedia(boolean hide) {
+ File nomediaDir = new File(FileUtil.getSubsonicDirectory(), ".nomedia");
+ if (hide && !nomediaDir.exists()) {
+ if (!nomediaDir.mkdir()) {
+ Log.w(TAG, "Failed to create " + nomediaDir);
+ }
+ } else if (nomediaDir.exists()) {
+ if (!nomediaDir.delete()) {
+ Log.w(TAG, "Failed to delete " + nomediaDir);
+ }
+ }
+ Util.toast(this, R.string.settings_hide_media_toast, false);
+ }
+
+ private void setMediaButtonsEnabled(boolean enabled) {
+ if (enabled) {
+ Util.registerMediaButtonEventReceiver(this);
+ } else {
+ Util.unregisterMediaButtonEventReceiver(this);
+ }
+ }
+
+ private void setCacheLocation(String path) {
+ File dir = new File(path);
+ if (!FileUtil.ensureDirectoryExistsAndIsReadWritable(dir)) {
+ Util.toast(this, R.string.settings_cache_location_error, false);
+
+ // Reset it to the default.
+ String defaultPath = FileUtil.getDefaultMusicDirectory().getPath();
+ if (!defaultPath.equals(path)) {
+ SharedPreferences prefs = Util.getPreferences(this);
+ SharedPreferences.Editor editor = prefs.edit();
+ editor.putString(Constants.PREFERENCES_KEY_CACHE_LOCATION, defaultPath);
+ editor.commit();
+ cacheLocation.setSummary(defaultPath);
+ cacheLocation.setText(defaultPath);
+ }
+
+ // Clear download queue.
+ DownloadService downloadService = DownloadServiceImpl.getInstance();
+ downloadService.clear();
+ }
+ }
+
+ private void testConnection(final int instance) {
+ ModalBackgroundTask<Boolean> task = new ModalBackgroundTask<Boolean>(this, false) {
+ private int previousInstance;
+
+ @Override
+ protected Boolean doInBackground() throws Throwable {
+ updateProgress(R.string.settings_testing_connection);
+
+ previousInstance = Util.getActiveServer(SettingsActivity.this);
+ testingConnection = true;
+ Util.setActiveServer(SettingsActivity.this, instance);
+ try {
+ MusicService musicService = MusicServiceFactory.getMusicService(SettingsActivity.this);
+ musicService.ping(SettingsActivity.this, this);
+ return musicService.isLicenseValid(SettingsActivity.this, null);
+ } finally {
+ Util.setActiveServer(SettingsActivity.this, previousInstance);
+ testingConnection = false;
+ }
+ }
+
+ @Override
+ protected void done(Boolean licenseValid) {
+ if (licenseValid) {
+ Util.toast(SettingsActivity.this, R.string.settings_testing_ok);
+ } else {
+ Util.toast(SettingsActivity.this, R.string.settings_testing_unlicensed);
+ }
+ }
+
+ @Override
+ protected void cancel() {
+ super.cancel();
+ Util.setActiveServer(SettingsActivity.this, previousInstance);
+ }
+
+ @Override
+ protected void error(Throwable error) {
+ Log.w(TAG, error.toString(), error);
+ new ErrorDialog(SettingsActivity.this, getResources().getString(R.string.settings_connection_failure) +
+ " " + getErrorMessage(error), false);
+ }
+ };
+ task.execute();
+ }
+
+ private class ServerSettings {
+ private EditTextPreference serverName;
+ private EditTextPreference serverUrl;
+ private EditTextPreference username;
+ private PreferenceScreen screen;
+
+ private ServerSettings(String instance) {
+
+ screen = (PreferenceScreen) findPreference("server" + instance);
+ serverName = (EditTextPreference) findPreference(Constants.PREFERENCES_KEY_SERVER_NAME + instance);
+ serverUrl = (EditTextPreference) findPreference(Constants.PREFERENCES_KEY_SERVER_URL + instance);
+ username = (EditTextPreference) findPreference(Constants.PREFERENCES_KEY_USERNAME + instance);
+
+ serverUrl.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object value) {
+ try {
+ String url = (String) value;
+ new URL(url);
+ if (!url.equals(url.trim()) || url.contains("@")) {
+ throw new Exception();
+ }
+ } catch (Exception x) {
+ new ErrorDialog(SettingsActivity.this, R.string.settings_invalid_url, false);
+ return false;
+ }
+ return true;
+ }
+ });
+
+ username.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object value) {
+ String username = (String) value;
+ if (username == null || !username.equals(username.trim())) {
+ new ErrorDialog(SettingsActivity.this, R.string.settings_invalid_username, false);
+ return false;
+ }
+ return true;
+ }
+ });
+ }
+
+ public void update() {
+ serverName.setSummary(serverName.getText());
+ serverUrl.setSummary(serverUrl.getText());
+ username.setSummary(username.getText());
+ screen.setSummary(serverUrl.getText());
+ screen.setTitle(serverName.getText());
+ }
+ }
+} \ No newline at end of file
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/SubsonicTabActivity.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/SubsonicTabActivity.java
new file mode 100644
index 00000000..8c9c0687
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/SubsonicTabActivity.java
@@ -0,0 +1,383 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.activity;
+
+import java.io.File;
+import java.io.PrintWriter;
+import java.util.LinkedList;
+import java.util.List;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageInfo;
+import android.graphics.Typeface;
+import android.media.AudioManager;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Environment;
+import android.util.Log;
+import android.view.KeyEvent;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.Window;
+import android.widget.TextView;
+import net.sourceforge.subsonic.androidapp.R;
+import net.sourceforge.subsonic.androidapp.domain.MusicDirectory;
+import net.sourceforge.subsonic.androidapp.service.DownloadService;
+import net.sourceforge.subsonic.androidapp.service.DownloadServiceImpl;
+import net.sourceforge.subsonic.androidapp.service.MusicService;
+import net.sourceforge.subsonic.androidapp.service.MusicServiceFactory;
+import net.sourceforge.subsonic.androidapp.util.Constants;
+import net.sourceforge.subsonic.androidapp.util.ImageLoader;
+import net.sourceforge.subsonic.androidapp.util.ModalBackgroundTask;
+import net.sourceforge.subsonic.androidapp.util.Util;
+
+/**
+ * @author Sindre Mehus
+ */
+public class SubsonicTabActivity extends Activity {
+
+ private static final String TAG = SubsonicTabActivity.class.getSimpleName();
+ private static ImageLoader IMAGE_LOADER;
+
+ private boolean destroyed;
+ private View homeButton;
+ private View musicButton;
+ private View searchButton;
+ private View playlistButton;
+ private View nowPlayingButton;
+
+ @Override
+ protected void onCreate(Bundle bundle) {
+ setUncaughtExceptionHandler();
+ applyTheme();
+ super.onCreate(bundle);
+ requestWindowFeature(Window.FEATURE_NO_TITLE);
+ startService(new Intent(this, DownloadServiceImpl.class));
+ setVolumeControlStream(AudioManager.STREAM_MUSIC);
+ }
+
+ @Override
+ protected void onPostCreate(Bundle bundle) {
+ super.onPostCreate(bundle);
+
+ homeButton = findViewById(R.id.button_bar_home);
+ homeButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ Intent intent = new Intent(SubsonicTabActivity.this, MainActivity.class);
+ intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
+ Util.startActivityWithoutTransition(SubsonicTabActivity.this, intent);
+ }
+ });
+
+ musicButton = findViewById(R.id.button_bar_music);
+ musicButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ Intent intent = new Intent(SubsonicTabActivity.this, SelectArtistActivity.class);
+ intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
+ Util.startActivityWithoutTransition(SubsonicTabActivity.this, intent);
+ }
+ });
+
+ searchButton = findViewById(R.id.button_bar_search);
+ searchButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ Intent intent = new Intent(SubsonicTabActivity.this, SearchActivity.class);
+ intent.putExtra(Constants.INTENT_EXTRA_REQUEST_SEARCH, true);
+ Util.startActivityWithoutTransition(SubsonicTabActivity.this, intent);
+ }
+ });
+
+ playlistButton = findViewById(R.id.button_bar_playlists);
+ playlistButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ Intent intent = new Intent(SubsonicTabActivity.this, SelectPlaylistActivity.class);
+ intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
+ Util.startActivityWithoutTransition(SubsonicTabActivity.this, intent);
+ }
+ });
+
+ nowPlayingButton = findViewById(R.id.button_bar_now_playing);
+ nowPlayingButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ Util.startActivityWithoutTransition(SubsonicTabActivity.this, DownloadActivity.class);
+ }
+ });
+
+ if (this instanceof MainActivity) {
+ homeButton.setEnabled(false);
+ } else if (this instanceof SelectAlbumActivity || this instanceof SelectArtistActivity) {
+ musicButton.setEnabled(false);
+ } else if (this instanceof SearchActivity) {
+ searchButton.setEnabled(false);
+ } else if (this instanceof SelectPlaylistActivity) {
+ playlistButton.setEnabled(false);
+ } else if (this instanceof DownloadActivity || this instanceof LyricsActivity) {
+ nowPlayingButton.setEnabled(false);
+ }
+
+ updateButtonVisibility();
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ Util.registerMediaButtonEventReceiver(this);
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ MenuInflater inflater = getMenuInflater();
+ inflater.inflate(R.menu.main, menu);
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ switch (item.getItemId()) {
+
+ case R.id.menu_exit:
+ Intent intent = new Intent(this, MainActivity.class);
+ intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
+ intent.putExtra(Constants.INTENT_EXTRA_NAME_EXIT, true);
+ Util.startActivityWithoutTransition(this, intent);
+ return true;
+
+ case R.id.menu_settings:
+ startActivity(new Intent(this, SettingsActivity.class));
+ return true;
+
+ case R.id.menu_help:
+ startActivity(new Intent(this, HelpActivity.class));
+ return true;
+ }
+
+ return false;
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+ destroyed = true;
+ getImageLoader().clear();
+ }
+
+
+ @Override
+ public boolean onKeyDown(int keyCode, KeyEvent event) {
+ boolean isVolumeDown = keyCode == KeyEvent.KEYCODE_VOLUME_DOWN;
+ boolean isVolumeUp = keyCode == KeyEvent.KEYCODE_VOLUME_UP;
+ boolean isVolumeAdjust = isVolumeDown || isVolumeUp;
+ boolean isJukebox = getDownloadService() != null && getDownloadService().isJukeboxEnabled();
+
+ if (isVolumeAdjust && isJukebox) {
+ getDownloadService().adjustJukeboxVolume(isVolumeUp);
+ return true;
+ }
+ return super.onKeyDown(keyCode, event);
+ }
+
+ @Override
+ public void finish() {
+ super.finish();
+ Util.disablePendingTransition(this);
+ }
+
+ @Override
+ public void setTitle(CharSequence title) {
+ super.setTitle(title);
+
+ // Set the font of title in the action bar.
+ TextView text = (TextView) findViewById(R.id.actionbar_title_text);
+ Typeface typeface = Typeface.createFromAsset(getAssets(), "fonts/Storopia.ttf");
+ text.setTypeface(typeface);
+
+ text.setText(title);
+ }
+
+ @Override
+ public void setTitle(int titleId) {
+ setTitle(getString(titleId));
+ }
+
+ private void applyTheme() {
+ String theme = Util.getTheme(this);
+ if ("dark".equals(theme)) {
+ setTheme(android.R.style.Theme);
+ } else if ("light".equals(theme)) {
+ setTheme(android.R.style.Theme_Light);
+ }
+ }
+
+ public boolean isDestroyed() {
+ return destroyed;
+ }
+
+ private void updateButtonVisibility() {
+ int visibility = Util.isOffline(this) ? View.GONE : View.VISIBLE;
+ searchButton.setVisibility(visibility);
+ playlistButton.setVisibility(visibility);
+ }
+
+ public void setProgressVisible(boolean visible) {
+ View view = findViewById(R.id.tab_progress);
+ if (view != null) {
+ view.setVisibility(visible ? View.VISIBLE : View.GONE);
+ }
+ }
+
+ public void updateProgress(String message) {
+ TextView view = (TextView) findViewById(R.id.tab_progress_message);
+ if (view != null) {
+ view.setText(message);
+ }
+ }
+
+ public DownloadService getDownloadService() {
+ // If service is not available, request it to start and wait for it.
+ for (int i = 0; i < 5; i++) {
+ DownloadService downloadService = DownloadServiceImpl.getInstance();
+ if (downloadService != null) {
+ return downloadService;
+ }
+ Log.w(TAG, "DownloadService not running. Attempting to start it.");
+ startService(new Intent(this, DownloadServiceImpl.class));
+ Util.sleepQuietly(50L);
+ }
+ return DownloadServiceImpl.getInstance();
+ }
+
+ protected void warnIfNetworkOrStorageUnavailable() {
+ if (!Util.isExternalStoragePresent()) {
+ Util.toast(this, R.string.select_album_no_sdcard);
+ } else if (!Util.isOffline(this) && !Util.isNetworkConnected(this)) {
+ Util.toast(this, R.string.select_album_no_network);
+ }
+ }
+
+ protected synchronized ImageLoader getImageLoader() {
+ if (IMAGE_LOADER == null) {
+ IMAGE_LOADER = new ImageLoader(this);
+ }
+ return IMAGE_LOADER;
+ }
+
+ protected void downloadRecursively(final String id, final boolean save, final boolean append, final boolean autoplay) {
+ ModalBackgroundTask<List<MusicDirectory.Entry>> task = new ModalBackgroundTask<List<MusicDirectory.Entry>>(this, false) {
+
+ private static final int MAX_SONGS = 500;
+
+ @Override
+ protected List<MusicDirectory.Entry> doInBackground() throws Throwable {
+ MusicService musicService = MusicServiceFactory.getMusicService(SubsonicTabActivity.this);
+ MusicDirectory root = musicService.getMusicDirectory(id, false, SubsonicTabActivity.this, this);
+ List<MusicDirectory.Entry> songs = new LinkedList<MusicDirectory.Entry>();
+ getSongsRecursively(root, songs);
+ return songs;
+ }
+
+ private void getSongsRecursively(MusicDirectory parent, List<MusicDirectory.Entry> songs) throws Exception {
+ if (songs.size() > MAX_SONGS) {
+ return;
+ }
+
+ for (MusicDirectory.Entry song : parent.getChildren(false, true)) {
+ if (!song.isVideo()) {
+ songs.add(song);
+ }
+ }
+ for (MusicDirectory.Entry dir : parent.getChildren(true, false)) {
+ MusicService musicService = MusicServiceFactory.getMusicService(SubsonicTabActivity.this);
+ getSongsRecursively(musicService.getMusicDirectory(dir.getId(), false, SubsonicTabActivity.this, this), songs);
+ }
+ }
+
+ @Override
+ protected void done(List<MusicDirectory.Entry> songs) {
+ DownloadService downloadService = getDownloadService();
+ if (!songs.isEmpty() && downloadService != null) {
+ if (!append) {
+ downloadService.clear();
+ }
+ warnIfNetworkOrStorageUnavailable();
+ downloadService.download(songs, save, autoplay, false);
+ Util.startActivityWithoutTransition(SubsonicTabActivity.this, DownloadActivity.class);
+ }
+ }
+ };
+
+ task.execute();
+ }
+
+ private void setUncaughtExceptionHandler() {
+ Thread.UncaughtExceptionHandler handler = Thread.getDefaultUncaughtExceptionHandler();
+ if (!(handler instanceof SubsonicUncaughtExceptionHandler)) {
+ Thread.setDefaultUncaughtExceptionHandler(new SubsonicUncaughtExceptionHandler(this));
+ }
+ }
+
+ /**
+ * Logs the stack trace of uncaught exceptions to a file on the SD card.
+ */
+ private static class SubsonicUncaughtExceptionHandler implements Thread.UncaughtExceptionHandler {
+
+ private final Thread.UncaughtExceptionHandler defaultHandler;
+ private final Context context;
+
+ private SubsonicUncaughtExceptionHandler(Context context) {
+ this.context = context;
+ defaultHandler = Thread.getDefaultUncaughtExceptionHandler();
+ }
+
+ @Override
+ public void uncaughtException(Thread thread, Throwable throwable) {
+ File file = null;
+ PrintWriter printWriter = null;
+ try {
+
+ PackageInfo packageInfo = context.getPackageManager().getPackageInfo("net.sourceforge.subsonic.androidapp", 0);
+ file = new File(Environment.getExternalStorageDirectory(), "subsonic-stacktrace.txt");
+ printWriter = new PrintWriter(file);
+ printWriter.println("Android API level: " + Build.VERSION.SDK);
+ printWriter.println("Subsonic version name: " + packageInfo.versionName);
+ printWriter.println("Subsonic version code: " + packageInfo.versionCode);
+ printWriter.println();
+ throwable.printStackTrace(printWriter);
+ Log.i(TAG, "Stack trace written to " + file);
+ } catch (Throwable x) {
+ Log.e(TAG, "Failed to write stack trace to " + file, x);
+ } finally {
+ Util.close(printWriter);
+ if (defaultHandler != null) {
+ defaultHandler.uncaughtException(thread, throwable);
+ }
+
+ }
+ }
+ }
+}
+
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/VoiceQueryReceiverActivity.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/VoiceQueryReceiverActivity.java
new file mode 100644
index 00000000..205c2fe7
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/activity/VoiceQueryReceiverActivity.java
@@ -0,0 +1,59 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+
+package net.sourceforge.subsonic.androidapp.activity;
+
+import android.app.Activity;
+import android.app.SearchManager;
+import android.content.Intent;
+import android.os.Bundle;
+import android.provider.SearchRecentSuggestions;
+import net.sourceforge.subsonic.androidapp.util.Constants;
+import net.sourceforge.subsonic.androidapp.util.Util;
+import net.sourceforge.subsonic.androidapp.provider.SearchSuggestionProvider;
+
+/**
+ * Receives voice search queries and forwards to the SearchActivity.
+ *
+ * http://android-developers.blogspot.com/2010/09/supporting-new-music-voice-action.html
+ *
+ * @author Sindre Mehus
+ */
+public class VoiceQueryReceiverActivity extends Activity {
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ String query = getIntent().getStringExtra(SearchManager.QUERY);
+
+ if (query != null) {
+ SearchRecentSuggestions suggestions = new SearchRecentSuggestions(this, SearchSuggestionProvider.AUTHORITY,
+ SearchSuggestionProvider.MODE);
+ suggestions.saveRecentQuery(query, null);
+
+ Intent intent = new Intent(VoiceQueryReceiverActivity.this, SearchActivity.class);
+ intent.putExtra(Constants.INTENT_EXTRA_NAME_QUERY, query);
+ intent.putExtra(Constants.INTENT_EXTRA_NAME_AUTOPLAY, true);
+ Util.startActivityWithoutTransition(VoiceQueryReceiverActivity.this, intent);
+ }
+ finish();
+ Util.disablePendingTransition(this);
+ }
+} \ No newline at end of file
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/audiofx/EqualizerController.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/audiofx/EqualizerController.java
new file mode 100644
index 00000000..77a270ed
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/audiofx/EqualizerController.java
@@ -0,0 +1,138 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2011 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.audiofx;
+
+import java.io.Serializable;
+
+import android.content.Context;
+import android.media.MediaPlayer;
+import android.media.audiofx.Equalizer;
+import android.util.Log;
+import net.sourceforge.subsonic.androidapp.util.FileUtil;
+
+/**
+ * Backward-compatible wrapper for {@link Equalizer}, which is API Level 9.
+ *
+ * @author Sindre Mehus
+ * @version $Id$
+ */
+public class EqualizerController {
+
+ private static final String TAG = EqualizerController.class.getSimpleName();
+
+ private final Context context;
+ private Equalizer equalizer;
+
+ // Class initialization fails when this throws an exception.
+ static {
+ try {
+ Class.forName("android.media.audiofx.Equalizer");
+ } catch (Exception ex) {
+ throw new RuntimeException(ex);
+ }
+ }
+
+ /**
+ * Throws an exception if the {@link Equalizer} class is not available.
+ */
+ public static void checkAvailable() throws Throwable {
+ // Calling here forces class initialization.
+ }
+
+ public EqualizerController(Context context, MediaPlayer mediaPlayer) {
+ this.context = context;
+ try {
+ equalizer = new Equalizer(0, mediaPlayer.getAudioSessionId());
+ } catch (Throwable x) {
+ Log.w(TAG, "Failed to create equalizer.", x);
+ }
+ }
+
+ public void saveSettings() {
+ try {
+ if (isAvailable()) {
+ FileUtil.serialize(context, new EqualizerSettings(equalizer), "equalizer.dat");
+ }
+ } catch (Throwable x) {
+ Log.w(TAG, "Failed to save equalizer settings.", x);
+ }
+ }
+
+ public void loadSettings() {
+ try {
+ if (isAvailable()) {
+ EqualizerSettings settings = FileUtil.deserialize(context, "equalizer.dat");
+ if (settings != null) {
+ settings.apply(equalizer);
+ }
+ }
+ } catch (Throwable x) {
+ Log.w(TAG, "Failed to load equalizer settings.", x);
+ }
+ }
+
+ public boolean isAvailable() {
+ return equalizer != null;
+ }
+
+ public boolean isEnabled() {
+ return isAvailable() && equalizer.getEnabled();
+ }
+
+ public void release() {
+ if (isAvailable()) {
+ equalizer.release();
+ }
+ }
+
+ public Equalizer getEqualizer() {
+ return equalizer;
+ }
+
+ private static class EqualizerSettings implements Serializable {
+
+ private final short[] bandLevels;
+ private short preset;
+ private final boolean enabled;
+
+ public EqualizerSettings(Equalizer equalizer) {
+ enabled = equalizer.getEnabled();
+ bandLevels = new short[equalizer.getNumberOfBands()];
+ for (short i = 0; i < equalizer.getNumberOfBands(); i++) {
+ bandLevels[i] = equalizer.getBandLevel(i);
+ }
+ try {
+ preset = equalizer.getCurrentPreset();
+ } catch (Exception x) {
+ preset = -1;
+ }
+ }
+
+ public void apply(Equalizer equalizer) {
+ for (short i = 0; i < bandLevels.length; i++) {
+ equalizer.setBandLevel(i, bandLevels[i]);
+ }
+ if (preset >= 0 && preset < equalizer.getNumberOfPresets()) {
+ equalizer.usePreset(preset);
+ }
+ equalizer.setEnabled(enabled);
+ }
+ }
+}
+
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/audiofx/VisualizerController.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/audiofx/VisualizerController.java
new file mode 100644
index 00000000..9a211b58
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/audiofx/VisualizerController.java
@@ -0,0 +1,90 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2011 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.audiofx;
+
+import android.content.Context;
+import android.media.MediaPlayer;
+import android.media.audiofx.Visualizer;
+import android.util.Log;
+
+/**
+ * Backward-compatible wrapper for {@link Visualizer}, which is API Level 9.
+ *
+ * @author Sindre Mehus
+ * @version $Id$
+ */
+public class VisualizerController {
+
+ private static final String TAG = VisualizerController.class.getSimpleName();
+ private static final int PREFERRED_CAPTURE_SIZE = 128; // Must be a power of two.
+
+ private final Context context;
+ private Visualizer visualizer;
+
+ // Class initialization fails when this throws an exception.
+ static {
+ try {
+ Class.forName("android.media.audiofx.Visualizer");
+ } catch (Exception ex) {
+ throw new RuntimeException(ex);
+ }
+ }
+
+ /**
+ * Throws an exception if the {@link Visualizer} class is not available.
+ */
+ public static void checkAvailable() throws Throwable {
+ // Calling here forces class initialization.
+ }
+
+ public VisualizerController(Context context, MediaPlayer mediaPlayer) {
+ this.context = context;
+ try {
+ visualizer = new Visualizer(mediaPlayer.getAudioSessionId());
+ } catch (Throwable x) {
+ Log.w(TAG, "Failed to create visualizer.", x);
+ }
+
+ if (visualizer != null) {
+ int[] captureSizeRange = Visualizer.getCaptureSizeRange();
+ int captureSize = Math.max(PREFERRED_CAPTURE_SIZE, captureSizeRange[0]);
+ captureSize = Math.min(captureSize, captureSizeRange[1]);
+ visualizer.setCaptureSize(captureSize);
+ }
+ }
+
+ public boolean isAvailable() {
+ return visualizer != null;
+ }
+
+ public boolean isEnabled() {
+ return isAvailable() && visualizer.getEnabled();
+ }
+
+ public void release() {
+ if (isAvailable()) {
+ visualizer.release();
+ }
+ }
+
+ public Visualizer getVisualizer() {
+ return visualizer;
+ }
+}
+
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/Artist.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/Artist.java
new file mode 100644
index 00000000..fce7b628
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/Artist.java
@@ -0,0 +1,60 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.domain;
+
+import java.io.Serializable;
+
+/**
+ * @author Sindre Mehus
+ */
+public class Artist implements Serializable {
+
+ private String id;
+ private String name;
+ private String index;
+
+ public String getId() {
+ return id;
+ }
+
+ public void setId(String id) {
+ this.id = id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public String getIndex() {
+ return index;
+ }
+
+ public void setIndex(String index) {
+ this.index = index;
+ }
+
+ @Override
+ public String toString() {
+ return name;
+ }
+} \ No newline at end of file
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/Indexes.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/Indexes.java
new file mode 100644
index 00000000..f16861be
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/Indexes.java
@@ -0,0 +1,50 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.domain;
+
+import java.util.List;
+import java.io.Serializable;
+
+/**
+ * @author Sindre Mehus
+ */
+public class Indexes implements Serializable {
+
+ private final long lastModified;
+ private final List<Artist> shortcuts;
+ private final List<Artist> artists;
+
+ public Indexes(long lastModified, List<Artist> shortcuts, List<Artist> artists) {
+ this.lastModified = lastModified;
+ this.shortcuts = shortcuts;
+ this.artists = artists;
+ }
+
+ public long getLastModified() {
+ return lastModified;
+ }
+
+ public List<Artist> getShortcuts() {
+ return shortcuts;
+ }
+
+ public List<Artist> getArtists() {
+ return artists;
+ }
+} \ No newline at end of file
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/JukeboxStatus.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/JukeboxStatus.java
new file mode 100644
index 00000000..53a901ad
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/JukeboxStatus.java
@@ -0,0 +1,63 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.domain;
+
+/**
+ * @author Sindre Mehus
+ * @version $Id$
+ */
+public class JukeboxStatus {
+
+ private Integer positionSeconds;
+ private Integer currentPlayingIndex;
+ private Float gain;
+ private boolean playing;
+
+ public Integer getPositionSeconds() {
+ return positionSeconds;
+ }
+
+ public void setPositionSeconds(Integer positionSeconds) {
+ this.positionSeconds = positionSeconds;
+ }
+
+ public Integer getCurrentPlayingIndex() {
+ return currentPlayingIndex;
+ }
+
+ public void setCurrentIndex(Integer currentPlayingIndex) {
+ this.currentPlayingIndex = currentPlayingIndex;
+ }
+
+ public boolean isPlaying() {
+ return playing;
+ }
+
+ public void setPlaying(boolean playing) {
+ this.playing = playing;
+ }
+
+ public Float getGain() {
+ return gain;
+ }
+
+ public void setGain(float gain) {
+ this.gain = gain;
+ }
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/Lyrics.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/Lyrics.java
new file mode 100644
index 00000000..c1a4c7c0
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/Lyrics.java
@@ -0,0 +1,55 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2010 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.domain;
+
+/**
+ * Song lyrics.
+ *
+ * @author Sindre Mehus
+ */
+public class Lyrics {
+
+ private String artist;
+ private String title;
+ private String text;
+
+ public String getArtist() {
+ return artist;
+ }
+
+ public void setArtist(String artist) {
+ this.artist = artist;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public void setTitle(String title) {
+ this.title = title;
+ }
+
+ public String getText() {
+ return text;
+ }
+
+ public void setText(String text) {
+ this.text = text;
+ }
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/MusicDirectory.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/MusicDirectory.java
new file mode 100644
index 00000000..4d4d265b
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/MusicDirectory.java
@@ -0,0 +1,259 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.domain;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.io.Serializable;
+
+/**
+ * @author Sindre Mehus
+ */
+public class MusicDirectory {
+
+ private String name;
+ private final List<Entry> children = new ArrayList<Entry>();
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public void addChild(Entry child) {
+ children.add(child);
+ }
+
+ public List<Entry> getChildren() {
+ return getChildren(true, true);
+ }
+
+ public List<Entry> getChildren(boolean includeDirs, boolean includeFiles) {
+ if (includeDirs && includeFiles) {
+ return children;
+ }
+
+ List<Entry> result = new ArrayList<Entry>(children.size());
+ for (Entry child : children) {
+ if (child.isDirectory() && includeDirs || !child.isDirectory() && includeFiles) {
+ result.add(child);
+ }
+ }
+ return result;
+ }
+
+ public static class Entry implements Serializable {
+ private String id;
+ private String parent;
+ private boolean directory;
+ private String title;
+ private String album;
+ private String artist;
+ private Integer track;
+ private Integer year;
+ private String genre;
+ private String contentType;
+ private String suffix;
+ private String transcodedContentType;
+ private String transcodedSuffix;
+ private String coverArt;
+ private Long size;
+ private Integer duration;
+ private Integer bitRate;
+ private String path;
+ private boolean video;
+
+ public String getId() {
+ return id;
+ }
+
+ public void setId(String id) {
+ this.id = id;
+ }
+
+ public String getParent() {
+ return parent;
+ }
+
+ public void setParent(String parent) {
+ this.parent = parent;
+ }
+
+ public boolean isDirectory() {
+ return directory;
+ }
+
+ public void setDirectory(boolean directory) {
+ this.directory = directory;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public void setTitle(String title) {
+ this.title = title;
+ }
+
+ public String getAlbum() {
+ return album;
+ }
+
+ public void setAlbum(String album) {
+ this.album = album;
+ }
+
+ public String getArtist() {
+ return artist;
+ }
+
+ public void setArtist(String artist) {
+ this.artist = artist;
+ }
+
+ public Integer getTrack() {
+ return track;
+ }
+
+ public void setTrack(Integer track) {
+ this.track = track;
+ }
+
+ public Integer getYear() {
+ return year;
+ }
+
+ public void setYear(Integer year) {
+ this.year = year;
+ }
+
+ public String getGenre() {
+ return genre;
+ }
+
+ public void setGenre(String genre) {
+ this.genre = genre;
+ }
+
+ public String getContentType() {
+ return contentType;
+ }
+
+ public void setContentType(String contentType) {
+ this.contentType = contentType;
+ }
+
+ public String getSuffix() {
+ return suffix;
+ }
+
+ public void setSuffix(String suffix) {
+ this.suffix = suffix;
+ }
+
+ public String getTranscodedContentType() {
+ return transcodedContentType;
+ }
+
+ public void setTranscodedContentType(String transcodedContentType) {
+ this.transcodedContentType = transcodedContentType;
+ }
+
+ public String getTranscodedSuffix() {
+ return transcodedSuffix;
+ }
+
+ public void setTranscodedSuffix(String transcodedSuffix) {
+ this.transcodedSuffix = transcodedSuffix;
+ }
+
+ public Long getSize() {
+ return size;
+ }
+
+ public void setSize(Long size) {
+ this.size = size;
+ }
+
+ public Integer getDuration() {
+ return duration;
+ }
+
+ public void setDuration(Integer duration) {
+ this.duration = duration;
+ }
+
+ public Integer getBitRate() {
+ return bitRate;
+ }
+
+ public void setBitRate(Integer bitRate) {
+ this.bitRate = bitRate;
+ }
+
+ public String getCoverArt() {
+ return coverArt;
+ }
+
+ public void setCoverArt(String coverArt) {
+ this.coverArt = coverArt;
+ }
+
+ public String getPath() {
+ return path;
+ }
+
+ public void setPath(String path) {
+ this.path = path;
+ }
+
+ public boolean isVideo() {
+ return video;
+ }
+
+ public void setVideo(boolean video) {
+ this.video = video;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+
+ Entry entry = (Entry) o;
+ return id.equals(entry.id);
+ }
+
+ @Override
+ public int hashCode() {
+ return id.hashCode();
+ }
+
+ @Override
+ public String toString() {
+ return title;
+ }
+ }
+} \ No newline at end of file
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/MusicFolder.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/MusicFolder.java
new file mode 100644
index 00000000..595f2b5e
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/MusicFolder.java
@@ -0,0 +1,46 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.domain;
+
+import java.io.Serializable;
+
+/**
+ * Represents a top level directory in which music or other media is stored.
+ *
+ * @author Sindre Mehus
+ * @version $Id$
+ */
+public class MusicFolder implements Serializable {
+
+ private final String id;
+ private final String name;
+
+ public MusicFolder(String id, String name) {
+ this.id = id;
+ this.name = name;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public String getName() {
+ return name;
+ }
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/PlayerState.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/PlayerState.java
new file mode 100644
index 00000000..0e13159b
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/PlayerState.java
@@ -0,0 +1,34 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.domain;
+
+/**
+ * @author Sindre Mehus
+ * @version $Id$
+ */
+public enum PlayerState {
+ IDLE,
+ DOWNLOADING,
+ PREPARING,
+ PREPARED,
+ STARTED,
+ STOPPED,
+ PAUSED,
+ COMPLETED
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/Playlist.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/Playlist.java
new file mode 100644
index 00000000..8bb29f76
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/Playlist.java
@@ -0,0 +1,56 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.domain;
+
+import java.io.Serializable;
+
+/**
+ * @author Sindre Mehus
+ */
+public class Playlist implements Serializable {
+
+ private String id;
+ private String name;
+
+ public Playlist(String id, String name) {
+ this.id = id;
+ this.name = name;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public void setId(String id) {
+ this.id = id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ @Override
+ public String toString() {
+ return name;
+ }
+} \ No newline at end of file
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/RepeatMode.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/RepeatMode.java
new file mode 100644
index 00000000..be2ad061
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/RepeatMode.java
@@ -0,0 +1,28 @@
+package net.sourceforge.subsonic.androidapp.domain;
+
+/**
+ * @author Sindre Mehus
+ * @version $Id$
+ */
+public enum RepeatMode {
+ OFF {
+ @Override
+ public RepeatMode next() {
+ return ALL;
+ }
+ },
+ ALL {
+ @Override
+ public RepeatMode next() {
+ return SINGLE;
+ }
+ },
+ SINGLE {
+ @Override
+ public RepeatMode next() {
+ return OFF;
+ }
+ };
+
+ public abstract RepeatMode next();
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/SearchCritera.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/SearchCritera.java
new file mode 100644
index 00000000..8f944b1a
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/SearchCritera.java
@@ -0,0 +1,55 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.domain;
+
+/**
+ * The criteria for a music search.
+ *
+ * @author Sindre Mehus
+ */
+public class SearchCritera {
+
+ private final String query;
+ private final int artistCount;
+ private final int albumCount;
+ private final int songCount;
+
+ public SearchCritera(String query, int artistCount, int albumCount, int songCount) {
+ this.query = query;
+ this.artistCount = artistCount;
+ this.albumCount = albumCount;
+ this.songCount = songCount;
+ }
+
+ public String getQuery() {
+ return query;
+ }
+
+ public int getArtistCount() {
+ return artistCount;
+ }
+
+ public int getAlbumCount() {
+ return albumCount;
+ }
+
+ public int getSongCount() {
+ return songCount;
+ }
+} \ No newline at end of file
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/SearchResult.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/SearchResult.java
new file mode 100644
index 00000000..54c91628
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/SearchResult.java
@@ -0,0 +1,51 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.domain;
+
+import java.util.List;
+
+/**
+ * The result of a search. Contains matching artists, albums and songs.
+ *
+ * @author Sindre Mehus
+ */
+public class SearchResult {
+
+ private final List<Artist> artists;
+ private final List<MusicDirectory.Entry> albums;
+ private final List<MusicDirectory.Entry> songs;
+
+ public SearchResult(List<Artist> artists, List<MusicDirectory.Entry> albums, List<MusicDirectory.Entry> songs) {
+ this.artists = artists;
+ this.albums = albums;
+ this.songs = songs;
+ }
+
+ public List<Artist> getArtists() {
+ return artists;
+ }
+
+ public List<MusicDirectory.Entry> getAlbums() {
+ return albums;
+ }
+
+ public List<MusicDirectory.Entry> getSongs() {
+ return songs;
+ }
+} \ No newline at end of file
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/ServerInfo.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/ServerInfo.java
new file mode 100644
index 00000000..9212c585
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/ServerInfo.java
@@ -0,0 +1,46 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2010 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.domain;
+
+/**
+ * Information about the Subsonic server.
+ *
+ * @author Sindre Mehus
+ */
+public class ServerInfo {
+
+ private boolean isLicenseValid;
+ private Version restVersion;
+
+ public boolean isLicenseValid() {
+ return isLicenseValid;
+ }
+
+ public void setLicenseValid(boolean licenseValid) {
+ isLicenseValid = licenseValid;
+ }
+
+ public Version getRestVersion() {
+ return restVersion;
+ }
+
+ public void setRestVersion(Version restVersion) {
+ this.restVersion = restVersion;
+ }
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/Version.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/Version.java
new file mode 100644
index 00000000..bd1643b5
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/domain/Version.java
@@ -0,0 +1,142 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.domain;
+
+/**
+ * Represents the version number of the Subsonic Android app.
+ *
+ * @author Sindre Mehus
+ * @version $Revision: 1.3 $ $Date: 2006/01/20 21:25:16 $
+ */
+public class Version implements Comparable<Version> {
+ private int major;
+ private int minor;
+ private int beta;
+ private int bugfix;
+
+ /**
+ * Creates a new version instance by parsing the given string.
+ * @param version A string of the format "1.27", "1.27.2" or "1.27.beta3".
+ */
+ public Version(String version) {
+ String[] s = version.split("\\.");
+ major = Integer.valueOf(s[0]);
+ minor = Integer.valueOf(s[1]);
+
+ if (s.length > 2) {
+ if (s[2].contains("beta")) {
+ beta = Integer.valueOf(s[2].replace("beta", ""));
+ } else {
+ bugfix = Integer.valueOf(s[2]);
+ }
+ }
+ }
+
+ public int getMajor() {
+ return major;
+ }
+
+ public int getMinor() {
+ return minor;
+ }
+
+ /**
+ * Return whether this object is equal to another.
+ * @param o Object to compare to.
+ * @return Whether this object is equals to another.
+ */
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+
+ final Version version = (Version) o;
+
+ if (beta != version.beta) return false;
+ if (bugfix != version.bugfix) return false;
+ if (major != version.major) return false;
+ return minor == version.minor;
+ }
+
+ /**
+ * Returns a hash code for this object.
+ * @return A hash code for this object.
+ */
+ public int hashCode() {
+ int result;
+ result = major;
+ result = 29 * result + minor;
+ result = 29 * result + beta;
+ result = 29 * result + bugfix;
+ return result;
+ }
+
+ /**
+ * Returns a string representation of the form "1.27", "1.27.2" or "1.27.beta3".
+ * @return A string representation of the form "1.27", "1.27.2" or "1.27.beta3".
+ */
+ public String toString() {
+ StringBuffer buf = new StringBuffer();
+ buf.append(major).append('.').append(minor);
+ if (beta != 0) {
+ buf.append(".beta").append(beta);
+ } else if (bugfix != 0) {
+ buf.append('.').append(bugfix);
+ }
+
+ return buf.toString();
+ }
+
+ /**
+ * Compares this object with the specified object for order.
+ * @param version The object to compare to.
+ * @return A negative integer, zero, or a positive integer as this object is less than, equal to, or
+ * greater than the specified object.
+ */
+ @Override
+ public int compareTo(Version version) {
+ if (major < version.major) {
+ return -1;
+ } else if (major > version.major) {
+ return 1;
+ }
+
+ if (minor < version.minor) {
+ return -1;
+ } else if (minor > version.minor) {
+ return 1;
+ }
+
+ if (bugfix < version.bugfix) {
+ return -1;
+ } else if (bugfix > version.bugfix) {
+ return 1;
+ }
+
+ int thisBeta = beta == 0 ? Integer.MAX_VALUE : beta;
+ int otherBeta = version.beta == 0 ? Integer.MAX_VALUE : version.beta;
+
+ if (thisBeta < otherBeta) {
+ return -1;
+ } else if (thisBeta > otherBeta) {
+ return 1;
+ }
+
+ return 0;
+ }
+} \ No newline at end of file
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/provider/SearchSuggestionProvider.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/provider/SearchSuggestionProvider.java
new file mode 100644
index 00000000..d3ba4f5c
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/provider/SearchSuggestionProvider.java
@@ -0,0 +1,36 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2010 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.provider;
+
+import android.content.SearchRecentSuggestionsProvider;
+
+/**
+ * Provides search suggestions based on recent searches.
+ *
+ * @author Sindre Mehus
+ */
+public class SearchSuggestionProvider extends SearchRecentSuggestionsProvider {
+
+ public static final String AUTHORITY = SearchSuggestionProvider.class.getName();
+ public static final int MODE = DATABASE_MODE_QUERIES;
+
+ public SearchSuggestionProvider() {
+ setupSuggestions(AUTHORITY, MODE);
+ }
+} \ No newline at end of file
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/provider/SubsonicAppWidgetProvider.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/provider/SubsonicAppWidgetProvider.java
new file mode 100644
index 00000000..dba3bdcd
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/provider/SubsonicAppWidgetProvider.java
@@ -0,0 +1,238 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2010 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.provider;
+
+import android.app.PendingIntent;
+import android.appwidget.AppWidgetManager;
+import android.appwidget.AppWidgetProvider;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.Bitmap.Config;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.PorterDuff.Mode;
+import android.graphics.PorterDuffXfermode;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.os.Environment;
+import android.util.Log;
+import android.view.KeyEvent;
+import android.widget.RemoteViews;
+import net.sourceforge.subsonic.androidapp.R;
+import net.sourceforge.subsonic.androidapp.activity.DownloadActivity;
+import net.sourceforge.subsonic.androidapp.activity.MainActivity;
+import net.sourceforge.subsonic.androidapp.domain.MusicDirectory;
+import net.sourceforge.subsonic.androidapp.service.DownloadService;
+import net.sourceforge.subsonic.androidapp.service.DownloadServiceImpl;
+import net.sourceforge.subsonic.androidapp.util.FileUtil;
+
+/**
+ * Simple widget to show currently playing album art along
+ * with play/pause and next track buttons.
+ * <p/>
+ * Based on source code from the stock Android Music app.
+ *
+ * @author Sindre Mehus
+ */
+public class SubsonicAppWidgetProvider extends AppWidgetProvider {
+
+ private static SubsonicAppWidgetProvider instance;
+ private static final String TAG = SubsonicAppWidgetProvider.class.getSimpleName();
+
+ public static synchronized SubsonicAppWidgetProvider getInstance() {
+ if (instance == null) {
+ instance = new SubsonicAppWidgetProvider();
+ }
+ return instance;
+ }
+
+ @Override
+ public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
+ defaultAppWidget(context, appWidgetIds);
+ }
+
+ /**
+ * Initialize given widgets to default state, where we launch Subsonic on default click
+ * and hide actions if service not running.
+ */
+ private void defaultAppWidget(Context context, int[] appWidgetIds) {
+ final Resources res = context.getResources();
+ final RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.appwidget);
+
+ views.setTextViewText(R.id.artist, res.getText(R.string.widget_initial_text));
+
+ linkButtons(context, views, false);
+ pushUpdate(context, appWidgetIds, views);
+ }
+
+ private void pushUpdate(Context context, int[] appWidgetIds, RemoteViews views) {
+ // Update specific list of appWidgetIds if given, otherwise default to all
+ final AppWidgetManager manager = AppWidgetManager.getInstance(context);
+ if (appWidgetIds != null) {
+ manager.updateAppWidget(appWidgetIds, views);
+ } else {
+ manager.updateAppWidget(new ComponentName(context, this.getClass()), views);
+ }
+ }
+
+ /**
+ * Handle a change notification coming over from {@link DownloadService}
+ */
+ public void notifyChange(Context context, DownloadService service, boolean playing) {
+ if (hasInstances(context)) {
+ performUpdate(context, service, null, playing);
+ }
+ }
+
+ /**
+ * Check against {@link AppWidgetManager} if there are any instances of this widget.
+ */
+ private boolean hasInstances(Context context) {
+ AppWidgetManager manager = AppWidgetManager.getInstance(context);
+ int[] appWidgetIds = manager.getAppWidgetIds(new ComponentName(context, getClass()));
+ return (appWidgetIds.length > 0);
+ }
+
+ /**
+ * Update all active widget instances by pushing changes
+ */
+ private void performUpdate(Context context, DownloadService service, int[] appWidgetIds, boolean playing) {
+ final Resources res = context.getResources();
+ final RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.appwidget);
+
+ MusicDirectory.Entry currentPlaying = service.getCurrentPlaying() == null ? null : service.getCurrentPlaying().getSong();
+ String title = currentPlaying == null ? null : currentPlaying.getTitle();
+ CharSequence artist = currentPlaying == null ? null : currentPlaying.getArtist();
+ CharSequence errorState = null;
+
+ // Show error message?
+ String status = Environment.getExternalStorageState();
+ if (status.equals(Environment.MEDIA_SHARED) ||
+ status.equals(Environment.MEDIA_UNMOUNTED)) {
+ errorState = res.getText(R.string.widget_sdcard_busy);
+ } else if (status.equals(Environment.MEDIA_REMOVED)) {
+ errorState = res.getText(R.string.widget_sdcard_missing);
+ } else if (currentPlaying == null) {
+ errorState = res.getText(R.string.widget_initial_text);
+ }
+
+ if (errorState != null) {
+ // Show error state to user
+ views.setTextViewText(R.id.title,null);
+ views.setTextViewText(R.id.artist, errorState);
+ views.setImageViewResource(R.id.appwidget_coverart, R.drawable.appwidget_art_default);
+ } else {
+ // No error, so show normal titles
+ views.setTextViewText(R.id.title, title);
+ views.setTextViewText(R.id.artist, artist);
+ }
+
+ // Set correct drawable for pause state
+ if (playing) {
+ views.setImageViewResource(R.id.control_play, R.drawable.ic_appwidget_music_pause);
+ } else {
+ views.setImageViewResource(R.id.control_play, R.drawable.ic_appwidget_music_play);
+ }
+
+ // Set the cover art
+ try {
+ int size = context.getResources().getDrawable(R.drawable.appwidget_art_default).getIntrinsicHeight();
+ Bitmap bitmap = currentPlaying == null ? null : FileUtil.getAlbumArtBitmap(context, currentPlaying, size);
+
+ if (bitmap == null) {
+ // Set default cover art
+ views.setImageViewResource(R.id.appwidget_coverart, R.drawable.appwidget_art_unknown);
+ } else {
+ bitmap = getRoundedCornerBitmap(bitmap);
+ views.setImageViewBitmap(R.id.appwidget_coverart, bitmap);
+ }
+ } catch (Exception x) {
+ Log.e(TAG, "Failed to load cover art", x);
+ views.setImageViewResource(R.id.appwidget_coverart, R.drawable.appwidget_art_unknown);
+ }
+
+ // Link actions buttons to intents
+ linkButtons(context, views, currentPlaying != null);
+
+ pushUpdate(context, appWidgetIds, views);
+ }
+
+ /**
+ * Round the corners of a bitmap for the cover art image
+ */
+ private static Bitmap getRoundedCornerBitmap(Bitmap bitmap) {
+ Bitmap output = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), Config.ARGB_8888);
+ Canvas canvas = new Canvas(output);
+
+ final int color = 0xff424242;
+ final Paint paint = new Paint();
+ final float roundPx = 10;
+
+ // Add extra width to the rect so the right side wont be rounded.
+ final Rect rect = new Rect(0, 0, bitmap.getWidth() + (int) roundPx, bitmap.getHeight());
+ final RectF rectF = new RectF(rect);
+
+ paint.setAntiAlias(true);
+ canvas.drawARGB(0, 0, 0, 0);
+ paint.setColor(color);
+ canvas.drawRoundRect(rectF, roundPx, roundPx, paint);
+
+ paint.setXfermode(new PorterDuffXfermode(Mode.SRC_IN));
+ canvas.drawBitmap(bitmap, rect, rect, paint);
+
+ return output;
+ }
+
+ /**
+ * Link up various button actions using {@link PendingIntent}.
+ *
+ * @param playerActive True if player is active in background, which means
+ * widget click will launch {@link DownloadActivity},
+ * otherwise we launch {@link MainActivity}.
+ */
+ private void linkButtons(Context context, RemoteViews views, boolean playerActive) {
+
+ Intent intent = new Intent(context, playerActive ? DownloadActivity.class : MainActivity.class);
+ PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, 0);
+ views.setOnClickPendingIntent(R.id.appwidget_coverart, pendingIntent);
+ views.setOnClickPendingIntent(R.id.appwidget_top, pendingIntent);
+
+ // Emulate media button clicks.
+ intent = new Intent("1");
+ intent.setComponent(new ComponentName(context, DownloadServiceImpl.class));
+ intent.putExtra(Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE));
+ pendingIntent = PendingIntent.getService(context, 0, intent, 0);
+ views.setOnClickPendingIntent(R.id.control_play, pendingIntent);
+
+ intent = new Intent("2"); // Use a unique action name to ensure a different PendingIntent to be created.
+ intent.setComponent(new ComponentName(context, DownloadServiceImpl.class));
+ intent.putExtra(Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_NEXT));
+ pendingIntent = PendingIntent.getService(context, 0, intent, 0);
+ views.setOnClickPendingIntent(R.id.control_next, pendingIntent);
+
+ intent = new Intent("3"); // Use a unique action name to ensure a different PendingIntent to be created.
+ intent.setComponent(new ComponentName(context, DownloadServiceImpl.class));
+ intent.putExtra(Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PREVIOUS));
+ pendingIntent = PendingIntent.getService(context, 0, intent, 0);
+ views.setOnClickPendingIntent(R.id.control_previous, pendingIntent);
+ }
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/receiver/BluetoothIntentReceiver.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/receiver/BluetoothIntentReceiver.java
new file mode 100644
index 00000000..6bf17ac2
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/receiver/BluetoothIntentReceiver.java
@@ -0,0 +1,53 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2010 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.receiver;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+import net.sourceforge.subsonic.androidapp.service.DownloadServiceImpl;
+import net.sourceforge.subsonic.androidapp.util.Util;
+
+/**
+ * Request media button focus when connected to Bluetooth A2DP.
+ *
+ * @author Sindre Mehus
+ */
+public class BluetoothIntentReceiver extends BroadcastReceiver {
+
+ private static final String TAG = BluetoothIntentReceiver.class.getSimpleName();
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ int state = intent.getIntExtra("android.bluetooth.a2dp.extra.SINK_STATE", -1);
+ Log.i(TAG, "android.bluetooth.a2dp.extra.SINK_STATE, state = " + state);
+ boolean connected = state == 2; // android.bluetooth.BluetoothA2dp.STATE_CONNECTED
+ if (connected) {
+ Log.i(TAG, "Connected to Bluetooth A2DP, requesting media button focus.");
+ Util.registerMediaButtonEventReceiver(context);
+ }
+
+ boolean disconnected = state == 0; // android.bluetooth.BluetoothA2dp.STATE_DISCONNECTED
+ if (disconnected) {
+ Log.i(TAG, "Disconnected from Bluetooth A2DP, requesting pause.");
+ context.sendBroadcast(new Intent(DownloadServiceImpl.CMD_PAUSE));
+ }
+ }
+} \ No newline at end of file
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/receiver/MediaButtonIntentReceiver.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/receiver/MediaButtonIntentReceiver.java
new file mode 100644
index 00000000..5287d933
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/receiver/MediaButtonIntentReceiver.java
@@ -0,0 +1,50 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2010 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.receiver;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+import android.view.KeyEvent;
+import net.sourceforge.subsonic.androidapp.service.DownloadServiceImpl;
+
+/**
+ * @author Sindre Mehus
+ */
+public class MediaButtonIntentReceiver extends BroadcastReceiver {
+
+ private static final String TAG = MediaButtonIntentReceiver.class.getSimpleName();
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ KeyEvent event = (KeyEvent) intent.getExtras().get(Intent.EXTRA_KEY_EVENT);
+ Log.i(TAG, "Got MEDIA_BUTTON key event: " + event);
+
+ Intent serviceIntent = new Intent(context, DownloadServiceImpl.class);
+ serviceIntent.putExtra(Intent.EXTRA_KEY_EVENT, event);
+ context.startService(serviceIntent);
+
+ try {
+ abortBroadcast();
+ } catch (Exception x) {
+ // Ignored.
+ }
+ }
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/CachedMusicService.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/CachedMusicService.java
new file mode 100644
index 00000000..a06f3995
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/CachedMusicService.java
@@ -0,0 +1,237 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.service;
+
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.http.HttpResponse;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import net.sourceforge.subsonic.androidapp.domain.Indexes;
+import net.sourceforge.subsonic.androidapp.domain.JukeboxStatus;
+import net.sourceforge.subsonic.androidapp.domain.Lyrics;
+import net.sourceforge.subsonic.androidapp.domain.MusicDirectory;
+import net.sourceforge.subsonic.androidapp.domain.MusicFolder;
+import net.sourceforge.subsonic.androidapp.domain.Playlist;
+import net.sourceforge.subsonic.androidapp.domain.SearchCritera;
+import net.sourceforge.subsonic.androidapp.domain.SearchResult;
+import net.sourceforge.subsonic.androidapp.domain.Version;
+import net.sourceforge.subsonic.androidapp.util.CancellableTask;
+import net.sourceforge.subsonic.androidapp.util.LRUCache;
+import net.sourceforge.subsonic.androidapp.util.ProgressListener;
+import net.sourceforge.subsonic.androidapp.util.TimeLimitedCache;
+import net.sourceforge.subsonic.androidapp.util.Util;
+
+/**
+ * @author Sindre Mehus
+ */
+public class CachedMusicService implements MusicService {
+
+ private static final int MUSIC_DIR_CACHE_SIZE = 20;
+ private static final int TTL_MUSIC_DIR = 5 * 60; // Five minutes
+
+ private final MusicService musicService;
+ private final LRUCache<String, TimeLimitedCache<MusicDirectory>> cachedMusicDirectories;
+ private final TimeLimitedCache<Boolean> cachedLicenseValid = new TimeLimitedCache<Boolean>(120, TimeUnit.SECONDS);
+ private final TimeLimitedCache<Indexes> cachedIndexes = new TimeLimitedCache<Indexes>(60 * 60, TimeUnit.SECONDS);
+ private final TimeLimitedCache<List<Playlist>> cachedPlaylists = new TimeLimitedCache<List<Playlist>>(60, TimeUnit.SECONDS);
+ private final TimeLimitedCache<List<MusicFolder>> cachedMusicFolders = new TimeLimitedCache<List<MusicFolder>>(10 * 3600, TimeUnit.SECONDS);
+ private String restUrl;
+
+ public CachedMusicService(MusicService musicService) {
+ this.musicService = musicService;
+ cachedMusicDirectories = new LRUCache<String, TimeLimitedCache<MusicDirectory>>(MUSIC_DIR_CACHE_SIZE);
+ }
+
+ @Override
+ public void ping(Context context, ProgressListener progressListener) throws Exception {
+ checkSettingsChanged(context);
+ musicService.ping(context, progressListener);
+ }
+
+ @Override
+ public boolean isLicenseValid(Context context, ProgressListener progressListener) throws Exception {
+ checkSettingsChanged(context);
+ Boolean result = cachedLicenseValid.get();
+ if (result == null) {
+ result = musicService.isLicenseValid(context, progressListener);
+ cachedLicenseValid.set(result, result ? 30L * 60L : 2L * 60L, TimeUnit.SECONDS);
+ }
+ return result;
+ }
+
+ @Override
+ public List<MusicFolder> getMusicFolders(boolean refresh, Context context, ProgressListener progressListener) throws Exception {
+ checkSettingsChanged(context);
+ if (refresh) {
+ cachedMusicFolders.clear();
+ }
+ List<MusicFolder> result = cachedMusicFolders.get();
+ if (result == null) {
+ result = musicService.getMusicFolders(refresh, context, progressListener);
+ cachedMusicFolders.set(result);
+ }
+ return result;
+ }
+
+ @Override
+ public Indexes getIndexes(String musicFolderId, boolean refresh, Context context, ProgressListener progressListener) throws Exception {
+ checkSettingsChanged(context);
+ if (refresh) {
+ cachedIndexes.clear();
+ cachedMusicFolders.clear();
+ cachedMusicDirectories.clear();
+ }
+ Indexes result = cachedIndexes.get();
+ if (result == null) {
+ result = musicService.getIndexes(musicFolderId, refresh, context, progressListener);
+ cachedIndexes.set(result);
+ }
+ return result;
+ }
+
+ @Override
+ public MusicDirectory getMusicDirectory(String id, boolean refresh, Context context, ProgressListener progressListener) throws Exception {
+ checkSettingsChanged(context);
+ TimeLimitedCache<MusicDirectory> cache = refresh ? null : cachedMusicDirectories.get(id);
+ MusicDirectory dir = cache == null ? null : cache.get();
+ if (dir == null) {
+ dir = musicService.getMusicDirectory(id, refresh, context, progressListener);
+ cache = new TimeLimitedCache<MusicDirectory>(TTL_MUSIC_DIR, TimeUnit.SECONDS);
+ cache.set(dir);
+ cachedMusicDirectories.put(id, cache);
+ }
+ return dir;
+ }
+
+ @Override
+ public SearchResult search(SearchCritera criteria, Context context, ProgressListener progressListener) throws Exception {
+ return musicService.search(criteria, context, progressListener);
+ }
+
+ @Override
+ public MusicDirectory getPlaylist(String id, Context context, ProgressListener progressListener) throws Exception {
+ return musicService.getPlaylist(id, context, progressListener);
+ }
+
+ @Override
+ public List<Playlist> getPlaylists(boolean refresh, Context context, ProgressListener progressListener) throws Exception {
+ checkSettingsChanged(context);
+ List<Playlist> result = refresh ? null : cachedPlaylists.get();
+ if (result == null) {
+ result = musicService.getPlaylists(refresh, context, progressListener);
+ cachedPlaylists.set(result);
+ }
+ return result;
+ }
+
+ @Override
+ public void createPlaylist(String id, String name, List<MusicDirectory.Entry> entries, Context context, ProgressListener progressListener) throws Exception {
+ musicService.createPlaylist(id, name, entries, context, progressListener);
+ }
+
+ @Override
+ public Lyrics getLyrics(String artist, String title, Context context, ProgressListener progressListener) throws Exception {
+ return musicService.getLyrics(artist, title, context, progressListener);
+ }
+
+ @Override
+ public void scrobble(String id, boolean submission, Context context, ProgressListener progressListener) throws Exception {
+ musicService.scrobble(id, submission, context, progressListener);
+ }
+
+ @Override
+ public MusicDirectory getAlbumList(String type, int size, int offset, Context context, ProgressListener progressListener) throws Exception {
+ return musicService.getAlbumList(type, size, offset, context, progressListener);
+ }
+
+ @Override
+ public MusicDirectory getRandomSongs(int size, Context context, ProgressListener progressListener) throws Exception {
+ return musicService.getRandomSongs(size, context, progressListener);
+ }
+
+ @Override
+ public Bitmap getCoverArt(Context context, MusicDirectory.Entry entry, int size, boolean saveToFile, ProgressListener progressListener) throws Exception {
+ return musicService.getCoverArt(context, entry, size, saveToFile, progressListener);
+ }
+
+ @Override
+ public HttpResponse getDownloadInputStream(Context context, MusicDirectory.Entry song, long offset, int maxBitrate, CancellableTask task) throws Exception {
+ return musicService.getDownloadInputStream(context, song, offset, maxBitrate, task);
+ }
+
+ @Override
+ public Version getLocalVersion(Context context) throws Exception {
+ return musicService.getLocalVersion(context);
+ }
+
+ @Override
+ public Version getLatestVersion(Context context, ProgressListener progressListener) throws Exception {
+ return musicService.getLatestVersion(context, progressListener);
+ }
+
+ @Override
+ public String getVideoUrl(Context context, String id) {
+ return musicService.getVideoUrl(context, id);
+ }
+
+ @Override
+ public JukeboxStatus updateJukeboxPlaylist(List<String> ids, Context context, ProgressListener progressListener) throws Exception {
+ return musicService.updateJukeboxPlaylist(ids, context, progressListener);
+ }
+
+ @Override
+ public JukeboxStatus skipJukebox(int index, int offsetSeconds, Context context, ProgressListener progressListener) throws Exception {
+ return musicService.skipJukebox(index, offsetSeconds, context, progressListener);
+ }
+
+ @Override
+ public JukeboxStatus stopJukebox(Context context, ProgressListener progressListener) throws Exception {
+ return musicService.stopJukebox(context, progressListener);
+ }
+
+ @Override
+ public JukeboxStatus startJukebox(Context context, ProgressListener progressListener) throws Exception {
+ return musicService.startJukebox(context, progressListener);
+ }
+
+ @Override
+ public JukeboxStatus getJukeboxStatus(Context context, ProgressListener progressListener) throws Exception {
+ return musicService.getJukeboxStatus(context, progressListener);
+ }
+
+ @Override
+ public JukeboxStatus setJukeboxGain(float gain, Context context, ProgressListener progressListener) throws Exception {
+ return musicService.setJukeboxGain(gain, context, progressListener);
+ }
+
+ private void checkSettingsChanged(Context context) {
+ String newUrl = Util.getRestUrl(context, null);
+ if (!Util.equals(newUrl, restUrl)) {
+ cachedMusicFolders.clear();
+ cachedMusicDirectories.clear();
+ cachedLicenseValid.clear();
+ cachedIndexes.clear();
+ cachedPlaylists.clear();
+ restUrl = newUrl;
+ }
+ }
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/DownloadFile.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/DownloadFile.java
new file mode 100644
index 00000000..46373afe
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/DownloadFile.java
@@ -0,0 +1,323 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.service;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+import android.content.Context;
+import android.os.PowerManager;
+import android.util.DisplayMetrics;
+import android.util.Log;
+import net.sourceforge.subsonic.androidapp.domain.MusicDirectory;
+import net.sourceforge.subsonic.androidapp.util.CancellableTask;
+import net.sourceforge.subsonic.androidapp.util.FileUtil;
+import net.sourceforge.subsonic.androidapp.util.Util;
+import net.sourceforge.subsonic.androidapp.util.CacheCleaner;
+
+import org.apache.http.HttpResponse;
+import org.apache.http.HttpStatus;
+
+/**
+ * @author Sindre Mehus
+ * @version $Id$
+ */
+public class DownloadFile {
+
+ private static final String TAG = DownloadFile.class.getSimpleName();
+ private final Context context;
+ private final MusicDirectory.Entry song;
+ private final File partialFile;
+ private final File completeFile;
+ private final File saveFile;
+
+ private final MediaStoreService mediaStoreService;
+ private CancellableTask downloadTask;
+ private boolean save;
+ private boolean failed;
+ private int bitRate;
+
+ public DownloadFile(Context context, MusicDirectory.Entry song, boolean save) {
+ this.context = context;
+ this.song = song;
+ this.save = save;
+ saveFile = FileUtil.getSongFile(context, song);
+ bitRate = Util.getMaxBitrate(context);
+ partialFile = new File(saveFile.getParent(), FileUtil.getBaseName(saveFile.getName()) +
+ "." + bitRate + ".partial." + FileUtil.getExtension(saveFile.getName()));
+ completeFile = new File(saveFile.getParent(), FileUtil.getBaseName(saveFile.getName()) +
+ ".complete." + FileUtil.getExtension(saveFile.getName()));
+ mediaStoreService = new MediaStoreService(context);
+ }
+
+ public MusicDirectory.Entry getSong() {
+ return song;
+ }
+
+ /**
+ * Returns the effective bit rate.
+ */
+ public int getBitRate() {
+ if (bitRate > 0) {
+ return bitRate;
+ }
+ return song.getBitRate() == null ? 160 : song.getBitRate();
+ }
+
+ public synchronized void download() {
+ FileUtil.createDirectoryForParent(saveFile);
+ failed = false;
+ downloadTask = new DownloadTask();
+ downloadTask.start();
+ }
+
+ public synchronized void cancelDownload() {
+ if (downloadTask != null) {
+ downloadTask.cancel();
+ }
+ }
+
+ public File getCompleteFile() {
+ if (saveFile.exists()) {
+ return saveFile;
+ }
+
+ if (completeFile.exists()) {
+ return completeFile;
+ }
+
+ return saveFile;
+ }
+
+ public File getPartialFile() {
+ return partialFile;
+ }
+
+ public boolean isSaved() {
+ return saveFile.exists();
+ }
+
+ public synchronized boolean isCompleteFileAvailable() {
+ return saveFile.exists() || completeFile.exists();
+ }
+
+ public synchronized boolean isWorkDone() {
+ return saveFile.exists() || (completeFile.exists() && !save);
+ }
+
+ public synchronized boolean isDownloading() {
+ return downloadTask != null && downloadTask.isRunning();
+ }
+
+ public synchronized boolean isDownloadCancelled() {
+ return downloadTask != null && downloadTask.isCancelled();
+ }
+
+ public boolean shouldSave() {
+ return save;
+ }
+
+ public boolean isFailed() {
+ return failed;
+ }
+
+ public void delete() {
+ cancelDownload();
+ Util.delete(partialFile);
+ Util.delete(completeFile);
+ Util.delete(saveFile);
+ mediaStoreService.deleteFromMediaStore(this);
+ }
+
+ public void unpin() {
+ if (saveFile.exists()) {
+ saveFile.renameTo(completeFile);
+ }
+ }
+
+ public boolean cleanup() {
+ boolean ok = true;
+ if (completeFile.exists() || saveFile.exists()) {
+ ok = Util.delete(partialFile);
+ }
+ if (saveFile.exists()) {
+ ok &= Util.delete(completeFile);
+ }
+ return ok;
+ }
+
+ // In support of LRU caching.
+ public void updateModificationDate() {
+ updateModificationDate(saveFile);
+ updateModificationDate(partialFile);
+ updateModificationDate(completeFile);
+ }
+
+ private void updateModificationDate(File file) {
+ if (file.exists()) {
+ boolean ok = file.setLastModified(System.currentTimeMillis());
+ if (!ok) {
+ Log.w(TAG, "Failed to set last-modified date on " + file);
+ }
+ }
+ }
+
+ @Override
+ public String toString() {
+ return "DownloadFile (" + song + ")";
+ }
+
+ private class DownloadTask extends CancellableTask {
+
+ @Override
+ public void execute() {
+
+ InputStream in = null;
+ FileOutputStream out = null;
+ PowerManager.WakeLock wakeLock = null;
+ try {
+
+ if (Util.isScreenLitOnDownload(context)) {
+ PowerManager pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE);
+ wakeLock = pm.newWakeLock(PowerManager.SCREEN_DIM_WAKE_LOCK | PowerManager.ON_AFTER_RELEASE, toString());
+ wakeLock.acquire();
+ Log.i(TAG, "Acquired wake lock " + wakeLock);
+ }
+
+ if (saveFile.exists()) {
+ Log.i(TAG, saveFile + " already exists. Skipping.");
+ return;
+ }
+ if (completeFile.exists()) {
+ if (save) {
+ Util.atomicCopy(completeFile, saveFile);
+ } else {
+ Log.i(TAG, completeFile + " already exists. Skipping.");
+ }
+ return;
+ }
+
+ MusicService musicService = MusicServiceFactory.getMusicService(context);
+
+ // Attempt partial HTTP GET, appending to the file if it exists.
+ HttpResponse response = musicService.getDownloadInputStream(context, song, partialFile.length(), bitRate, DownloadTask.this);
+ in = response.getEntity().getContent();
+ boolean partial = response.getStatusLine().getStatusCode() == HttpStatus.SC_PARTIAL_CONTENT;
+ if (partial) {
+ Log.i(TAG, "Executed partial HTTP GET, skipping " + partialFile.length() + " bytes");
+ }
+
+ out = new FileOutputStream(partialFile, partial);
+ long n = copy(in, out);
+ Log.i(TAG, "Downloaded " + n + " bytes to " + partialFile);
+ out.flush();
+ out.close();
+
+ if (isCancelled()) {
+ throw new Exception("Download of '" + song + "' was cancelled");
+ }
+
+ downloadAndSaveCoverArt(musicService);
+
+ if (save) {
+ Util.atomicCopy(partialFile, saveFile);
+ mediaStoreService.saveInMediaStore(DownloadFile.this);
+ } else {
+ Util.atomicCopy(partialFile, completeFile);
+ }
+
+ } catch (Exception x) {
+ Util.close(out);
+ Util.delete(completeFile);
+ Util.delete(saveFile);
+ if (!isCancelled()) {
+ failed = true;
+ Log.w(TAG, "Failed to download '" + song + "'.", x);
+ }
+
+ } finally {
+ Util.close(in);
+ Util.close(out);
+ if (wakeLock != null) {
+ wakeLock.release();
+ Log.i(TAG, "Released wake lock " + wakeLock);
+ }
+ new CacheCleaner(context, DownloadServiceImpl.getInstance()).clean();
+ }
+ }
+
+ @Override
+ public String toString() {
+ return "DownloadTask (" + song + ")";
+ }
+
+ private void downloadAndSaveCoverArt(MusicService musicService) throws Exception {
+ try {
+ if (song.getCoverArt() != null) {
+ DisplayMetrics metrics = context.getResources().getDisplayMetrics();
+ int size = Math.min(metrics.widthPixels, metrics.heightPixels);
+ musicService.getCoverArt(context, song, size, true, null);
+ }
+ } catch (Exception x) {
+ Log.e(TAG, "Failed to get cover art.", x);
+ }
+ }
+
+ private long copy(final InputStream in, OutputStream out) throws IOException, InterruptedException {
+
+ // Start a thread that will close the input stream if the task is
+ // cancelled, thus causing the copy() method to return.
+ new Thread() {
+ @Override
+ public void run() {
+ while (true) {
+ Util.sleepQuietly(3000L);
+ if (isCancelled()) {
+ Util.close(in);
+ return;
+ }
+ if (!isRunning()) {
+ return;
+ }
+ }
+ }
+ }.start();
+
+ byte[] buffer = new byte[1024 * 16];
+ long count = 0;
+ int n;
+ long lastLog = System.currentTimeMillis();
+
+ while (!isCancelled() && (n = in.read(buffer)) != -1) {
+ out.write(buffer, 0, n);
+ count += n;
+
+ long now = System.currentTimeMillis();
+ if (now - lastLog > 3000L) { // Only every so often.
+ Log.i(TAG, "Downloaded " + Util.formatBytes(count) + " of " + song);
+ lastLog = now;
+ }
+ }
+ return count;
+ }
+ }
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/DownloadService.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/DownloadService.java
new file mode 100644
index 00000000..b136bdbc
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/DownloadService.java
@@ -0,0 +1,112 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.service;
+
+import java.util.List;
+
+import net.sourceforge.subsonic.androidapp.audiofx.EqualizerController;
+import net.sourceforge.subsonic.androidapp.audiofx.VisualizerController;
+import net.sourceforge.subsonic.androidapp.domain.MusicDirectory;
+import net.sourceforge.subsonic.androidapp.domain.PlayerState;
+import net.sourceforge.subsonic.androidapp.domain.RepeatMode;
+
+/**
+ * @author Sindre Mehus
+ * @version $Id$
+ */
+public interface DownloadService {
+
+ void download(List<MusicDirectory.Entry> songs, boolean save, boolean autoplay, boolean playNext);
+
+ void setShufflePlayEnabled(boolean enabled);
+
+ boolean isShufflePlayEnabled();
+
+ void shuffle();
+
+ RepeatMode getRepeatMode();
+
+ void setRepeatMode(RepeatMode repeatMode);
+
+ boolean getKeepScreenOn();
+
+ void setKeepScreenOn(boolean screenOn);
+
+ boolean getShowVisualization();
+
+ void setShowVisualization(boolean showVisualization);
+
+ void clear();
+
+ void clearIncomplete();
+
+ int size();
+
+ void remove(DownloadFile downloadFile);
+
+ List<DownloadFile> getDownloads();
+
+ int getCurrentPlayingIndex();
+
+ DownloadFile getCurrentPlaying();
+
+ DownloadFile getCurrentDownloading();
+
+ void play(int index);
+
+ void seekTo(int position);
+
+ void previous();
+
+ void next();
+
+ void pause();
+
+ void start();
+
+ void reset();
+
+ PlayerState getPlayerState();
+
+ int getPlayerPosition();
+
+ int getPlayerDuration();
+
+ void delete(List<MusicDirectory.Entry> songs);
+
+ void unpin(List<MusicDirectory.Entry> songs);
+
+ DownloadFile forSong(MusicDirectory.Entry song);
+
+ long getDownloadListUpdateRevision();
+
+ void setSuggestedPlaylistName(String name);
+
+ String getSuggestedPlaylistName();
+
+ EqualizerController getEqualizerController();
+
+ VisualizerController getVisualizerController();
+
+ boolean isJukeboxEnabled();
+
+ void setJukeboxEnabled(boolean b);
+
+ void adjustJukeboxVolume(boolean up);
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/DownloadServiceImpl.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/DownloadServiceImpl.java
new file mode 100644
index 00000000..2e668fea
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/DownloadServiceImpl.java
@@ -0,0 +1,930 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.service;
+
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.media.AudioManager;
+import android.media.MediaPlayer;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.PowerManager;
+import android.util.Log;
+import net.sourceforge.subsonic.androidapp.audiofx.EqualizerController;
+import net.sourceforge.subsonic.androidapp.audiofx.VisualizerController;
+import net.sourceforge.subsonic.androidapp.domain.MusicDirectory;
+import net.sourceforge.subsonic.androidapp.domain.PlayerState;
+import net.sourceforge.subsonic.androidapp.domain.RepeatMode;
+import net.sourceforge.subsonic.androidapp.util.CancellableTask;
+import net.sourceforge.subsonic.androidapp.util.LRUCache;
+import net.sourceforge.subsonic.androidapp.util.ShufflePlayBuffer;
+import net.sourceforge.subsonic.androidapp.util.SimpleServiceBinder;
+import net.sourceforge.subsonic.androidapp.util.Util;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+
+import static net.sourceforge.subsonic.androidapp.domain.PlayerState.*;
+
+/**
+ * @author Sindre Mehus
+ * @version $Id$
+ */
+public class DownloadServiceImpl extends Service implements DownloadService {
+
+ private static final String TAG = DownloadServiceImpl.class.getSimpleName();
+
+ public static final String CMD_PLAY = "net.sourceforge.subsonic.androidapp.CMD_PLAY";
+ public static final String CMD_TOGGLEPAUSE = "net.sourceforge.subsonic.androidapp.CMD_TOGGLEPAUSE";
+ public static final String CMD_PAUSE = "net.sourceforge.subsonic.androidapp.CMD_PAUSE";
+ public static final String CMD_STOP = "net.sourceforge.subsonic.androidapp.CMD_STOP";
+ public static final String CMD_PREVIOUS = "net.sourceforge.subsonic.androidapp.CMD_PREVIOUS";
+ public static final String CMD_NEXT = "net.sourceforge.subsonic.androidapp.CMD_NEXT";
+
+ private final IBinder binder = new SimpleServiceBinder<DownloadService>(this);
+ private MediaPlayer mediaPlayer;
+ private final List<DownloadFile> downloadList = new ArrayList<DownloadFile>();
+ private final Handler handler = new Handler();
+ private final DownloadServiceLifecycleSupport lifecycleSupport = new DownloadServiceLifecycleSupport(this);
+ private final ShufflePlayBuffer shufflePlayBuffer = new ShufflePlayBuffer(this);
+
+ private final LRUCache<MusicDirectory.Entry, DownloadFile> downloadFileCache = new LRUCache<MusicDirectory.Entry, DownloadFile>(100);
+ private final List<DownloadFile> cleanupCandidates = new ArrayList<DownloadFile>();
+ private final Scrobbler scrobbler = new Scrobbler();
+ private final JukeboxService jukeboxService = new JukeboxService(this);
+ private DownloadFile currentPlaying;
+ private DownloadFile currentDownloading;
+ private CancellableTask bufferTask;
+ private PlayerState playerState = IDLE;
+ private boolean shufflePlay;
+ private long revision;
+ private static DownloadService instance;
+ private String suggestedPlaylistName;
+ private PowerManager.WakeLock wakeLock;
+ private boolean keepScreenOn = false;
+
+ private static boolean equalizerAvailable;
+ private static boolean visualizerAvailable;
+ private EqualizerController equalizerController;
+ private VisualizerController visualizerController;
+ private boolean showVisualization;
+ private boolean jukeboxEnabled;
+
+ static {
+ try {
+ EqualizerController.checkAvailable();
+ equalizerAvailable = true;
+ } catch (Throwable t) {
+ equalizerAvailable = false;
+ }
+ }
+ static {
+ try {
+ VisualizerController.checkAvailable();
+ visualizerAvailable = true;
+ } catch (Throwable t) {
+ visualizerAvailable = false;
+ }
+ }
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+
+ mediaPlayer = new MediaPlayer();
+ mediaPlayer.setWakeMode(this, PowerManager.PARTIAL_WAKE_LOCK);
+
+ mediaPlayer.setOnErrorListener(new MediaPlayer.OnErrorListener() {
+ @Override
+ public boolean onError(MediaPlayer mediaPlayer, int what, int more) {
+ handleError(new Exception("MediaPlayer error: " + what + " (" + more + ")"));
+ return false;
+ }
+ });
+
+ if (equalizerAvailable) {
+ equalizerController = new EqualizerController(this, mediaPlayer);
+ if (!equalizerController.isAvailable()) {
+ equalizerController = null;
+ } else {
+ equalizerController.loadSettings();
+ }
+ }
+ if (visualizerAvailable) {
+ visualizerController = new VisualizerController(this, mediaPlayer);
+ if (!visualizerController.isAvailable()) {
+ visualizerController = null;
+ }
+ }
+
+ PowerManager pm = (PowerManager)getSystemService(Context.POWER_SERVICE);
+ wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, this.getClass().getName());
+ wakeLock.setReferenceCounted(false);
+
+ instance = this;
+ lifecycleSupport.onCreate();
+ }
+
+ @Override
+ public void onStart(Intent intent, int startId) {
+ super.onStart(intent, startId);
+ lifecycleSupport.onStart(intent);
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ lifecycleSupport.onDestroy();
+ mediaPlayer.release();
+ shufflePlayBuffer.shutdown();
+ if (equalizerController != null) {
+ equalizerController.release();
+ }
+ if (visualizerController != null) {
+ visualizerController.release();
+ }
+
+ instance = null;
+ }
+
+ public static DownloadService getInstance() {
+ return instance;
+ }
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ return binder;
+ }
+
+ @Override
+ public synchronized void download(List<MusicDirectory.Entry> songs, boolean save, boolean autoplay, boolean playNext) {
+ shufflePlay = false;
+ int offset = 1;
+
+ if (songs.isEmpty()) {
+ return;
+ }
+ if (playNext) {
+ if (autoplay && getCurrentPlayingIndex() >= 0) {
+ offset = 0;
+ }
+ for (MusicDirectory.Entry song : songs) {
+ DownloadFile downloadFile = new DownloadFile(this, song, save);
+ downloadList.add(getCurrentPlayingIndex() + offset, downloadFile);
+ offset++;
+ }
+ revision++;
+ } else {
+ for (MusicDirectory.Entry song : songs) {
+ DownloadFile downloadFile = new DownloadFile(this, song, save);
+ downloadList.add(downloadFile);
+ }
+ revision++;
+ }
+ updateJukeboxPlaylist();
+
+ if (autoplay) {
+ play(0);
+ } else {
+ if (currentPlaying == null) {
+ currentPlaying = downloadList.get(0);
+ }
+ checkDownloads();
+ }
+ lifecycleSupport.serializeDownloadQueue();
+ }
+
+ private void updateJukeboxPlaylist() {
+ if (jukeboxEnabled) {
+ jukeboxService.updatePlaylist();
+ }
+ }
+
+ public void restore(List<MusicDirectory.Entry> songs, int currentPlayingIndex, int currentPlayingPosition) {
+ download(songs, false, false, false);
+ if (currentPlayingIndex != -1) {
+ play(currentPlayingIndex, false);
+ if (currentPlaying.isCompleteFileAvailable()) {
+ doPlay(currentPlaying, currentPlayingPosition, false);
+ }
+ }
+ }
+
+ @Override
+ public synchronized void setShufflePlayEnabled(boolean enabled) {
+ if (shufflePlay == enabled) {
+ return;
+ }
+
+ shufflePlay = enabled;
+ if (shufflePlay) {
+ clear();
+ checkDownloads();
+ }
+ }
+
+ @Override
+ public synchronized boolean isShufflePlayEnabled() {
+ return shufflePlay;
+ }
+
+ @Override
+ public synchronized void shuffle() {
+ Collections.shuffle(downloadList);
+ if (currentPlaying != null) {
+ downloadList.remove(getCurrentPlayingIndex());
+ downloadList.add(0, currentPlaying);
+ }
+ revision++;
+ lifecycleSupport.serializeDownloadQueue();
+ updateJukeboxPlaylist();
+ }
+
+ @Override
+ public RepeatMode getRepeatMode() {
+ return Util.getRepeatMode(this);
+ }
+
+ @Override
+ public void setRepeatMode(RepeatMode repeatMode) {
+ Util.setRepeatMode(this, repeatMode);
+ }
+
+ @Override
+ public boolean getKeepScreenOn() {
+ return keepScreenOn;
+ }
+
+ @Override
+ public void setKeepScreenOn(boolean keepScreenOn) {
+ this.keepScreenOn = keepScreenOn;
+ }
+
+ @Override
+ public boolean getShowVisualization() {
+ return showVisualization;
+ }
+
+ @Override
+ public void setShowVisualization(boolean showVisualization) {
+ this.showVisualization = showVisualization;
+ }
+
+ @Override
+ public synchronized DownloadFile forSong(MusicDirectory.Entry song) {
+ for (DownloadFile downloadFile : downloadList) {
+ if (downloadFile.getSong().equals(song)) {
+ return downloadFile;
+ }
+ }
+
+ DownloadFile downloadFile = downloadFileCache.get(song);
+ if (downloadFile == null) {
+ downloadFile = new DownloadFile(this, song, false);
+ downloadFileCache.put(song, downloadFile);
+ }
+ return downloadFile;
+ }
+
+ @Override
+ public synchronized void clear() {
+ clear(true);
+ }
+
+ @Override
+ public synchronized void clearIncomplete() {
+ reset();
+ Iterator<DownloadFile> iterator = downloadList.iterator();
+ while (iterator.hasNext()) {
+ DownloadFile downloadFile = iterator.next();
+ if (!downloadFile.isCompleteFileAvailable()) {
+ iterator.remove();
+ }
+ }
+ lifecycleSupport.serializeDownloadQueue();
+ updateJukeboxPlaylist();
+ }
+
+ @Override
+ public synchronized int size() {
+ return downloadList.size();
+ }
+
+ public synchronized void clear(boolean serialize) {
+ reset();
+ downloadList.clear();
+ revision++;
+ if (currentDownloading != null) {
+ currentDownloading.cancelDownload();
+ currentDownloading = null;
+ }
+ setCurrentPlaying(null, false);
+
+ if (serialize) {
+ lifecycleSupport.serializeDownloadQueue();
+ }
+ updateJukeboxPlaylist();
+ }
+
+ @Override
+ public synchronized void remove(DownloadFile downloadFile) {
+ if (downloadFile == currentDownloading) {
+ currentDownloading.cancelDownload();
+ currentDownloading = null;
+ }
+ if (downloadFile == currentPlaying) {
+ reset();
+ setCurrentPlaying(null, false);
+ }
+ downloadList.remove(downloadFile);
+ revision++;
+ lifecycleSupport.serializeDownloadQueue();
+ updateJukeboxPlaylist();
+ }
+
+ @Override
+ public synchronized void delete(List<MusicDirectory.Entry> songs) {
+ for (MusicDirectory.Entry song : songs) {
+ forSong(song).delete();
+ }
+ }
+
+ @Override
+ public synchronized void unpin(List<MusicDirectory.Entry> songs) {
+ for (MusicDirectory.Entry song : songs) {
+ forSong(song).unpin();
+ }
+ }
+
+ synchronized void setCurrentPlaying(int currentPlayingIndex, boolean showNotification) {
+ try {
+ setCurrentPlaying(downloadList.get(currentPlayingIndex), showNotification);
+ } catch (IndexOutOfBoundsException x) {
+ // Ignored
+ }
+ }
+
+ synchronized void setCurrentPlaying(DownloadFile currentPlaying, boolean showNotification) {
+ this.currentPlaying = currentPlaying;
+
+ if (currentPlaying != null) {
+ Util.broadcastNewTrackInfo(this, currentPlaying.getSong());
+ } else {
+ Util.broadcastNewTrackInfo(this, null);
+ }
+
+ if (currentPlaying != null && showNotification) {
+ Util.showPlayingNotification(this, this, handler, currentPlaying.getSong());
+ } else {
+ Util.hidePlayingNotification(this, this, handler);
+ }
+ }
+
+ @Override
+ public synchronized int getCurrentPlayingIndex() {
+ return downloadList.indexOf(currentPlaying);
+ }
+
+ @Override
+ public DownloadFile getCurrentPlaying() {
+ return currentPlaying;
+ }
+
+ @Override
+ public DownloadFile getCurrentDownloading() {
+ return currentDownloading;
+ }
+
+ @Override
+ public synchronized List<DownloadFile> getDownloads() {
+ return new ArrayList<DownloadFile>(downloadList);
+ }
+
+ /** Plays either the current song (resume) or the first/next one in queue. */
+ public synchronized void play()
+ {
+ int current = getCurrentPlayingIndex();
+ if (current == -1) {
+ play(0);
+ } else {
+ play(current);
+ }
+ }
+
+ @Override
+ public synchronized void play(int index) {
+ play(index, true);
+ }
+
+ private synchronized void play(int index, boolean start) {
+ if (index < 0 || index >= size()) {
+ reset();
+ setCurrentPlaying(null, false);
+ } else {
+ setCurrentPlaying(index, start);
+ checkDownloads();
+ if (start) {
+ if (jukeboxEnabled) {
+ jukeboxService.skip(getCurrentPlayingIndex(), 0);
+ setPlayerState(STARTED);
+ } else {
+ bufferAndPlay();
+ }
+ }
+ }
+ }
+
+ /** Plays or resumes the playback, depending on the current player state. */
+ public synchronized void togglePlayPause()
+ {
+ if (playerState == PAUSED || playerState == COMPLETED) {
+ start();
+ } else if (playerState == STOPPED || playerState == IDLE) {
+ play();
+ } else if (playerState == STARTED) {
+ pause();
+ }
+ }
+
+ @Override
+ public synchronized void seekTo(int position) {
+ try {
+ if (jukeboxEnabled) {
+ jukeboxService.skip(getCurrentPlayingIndex(), position / 1000);
+ } else {
+ mediaPlayer.seekTo(position);
+ }
+ } catch (Exception x) {
+ handleError(x);
+ }
+ }
+
+ @Override
+ public synchronized void previous() {
+ int index = getCurrentPlayingIndex();
+ if (index == -1) {
+ return;
+ }
+
+ // Restart song if played more than five seconds.
+ if (getPlayerPosition() > 5000 || index == 0) {
+ play(index);
+ } else {
+ play(index - 1);
+ }
+ }
+
+ @Override
+ public synchronized void next() {
+ int index = getCurrentPlayingIndex();
+ if (index != -1) {
+ play(index + 1);
+ }
+ }
+
+ private void onSongCompleted() {
+ int index = getCurrentPlayingIndex();
+ if (index != -1) {
+ switch (getRepeatMode()) {
+ case OFF:
+ play(index + 1);
+ break;
+ case ALL:
+ play((index + 1) % size());
+ break;
+ case SINGLE:
+ play(index);
+ break;
+ default:
+ break;
+ }
+ }
+ }
+
+ @Override
+ public synchronized void pause() {
+ try {
+ if (playerState == STARTED) {
+ if (jukeboxEnabled) {
+ jukeboxService.stop();
+ } else {
+ mediaPlayer.pause();
+ }
+ setPlayerState(PAUSED);
+ }
+ } catch (Exception x) {
+ handleError(x);
+ }
+ }
+
+ @Override
+ public synchronized void start() {
+ try {
+ if (jukeboxEnabled) {
+ jukeboxService.start();
+ } else {
+ mediaPlayer.start();
+ }
+ setPlayerState(STARTED);
+ } catch (Exception x) {
+ handleError(x);
+ }
+ }
+
+ @Override
+ public synchronized void reset() {
+ if (bufferTask != null) {
+ bufferTask.cancel();
+ }
+ try {
+ mediaPlayer.reset();
+ setPlayerState(IDLE);
+ } catch (Exception x) {
+ handleError(x);
+ }
+ }
+
+ @Override
+ public synchronized int getPlayerPosition() {
+ try {
+ if (playerState == IDLE || playerState == DOWNLOADING || playerState == PREPARING) {
+ return 0;
+ }
+ if (jukeboxEnabled) {
+ return jukeboxService.getPositionSeconds() * 1000;
+ } else {
+ return mediaPlayer.getCurrentPosition();
+ }
+ } catch (Exception x) {
+ handleError(x);
+ return 0;
+ }
+ }
+
+ @Override
+ public synchronized int getPlayerDuration() {
+ if (currentPlaying != null) {
+ Integer duration = currentPlaying.getSong().getDuration();
+ if (duration != null) {
+ return duration * 1000;
+ }
+ }
+ if (playerState != IDLE && playerState != DOWNLOADING && playerState != PlayerState.PREPARING) {
+ try {
+ return mediaPlayer.getDuration();
+ } catch (Exception x) {
+ handleError(x);
+ }
+ }
+ return 0;
+ }
+
+ @Override
+ public PlayerState getPlayerState() {
+ return playerState;
+ }
+
+ synchronized void setPlayerState(PlayerState playerState) {
+ Log.i(TAG, this.playerState.name() + " -> " + playerState.name() + " (" + currentPlaying + ")");
+
+ if (playerState == PAUSED) {
+ lifecycleSupport.serializeDownloadQueue();
+ }
+
+ boolean show = this.playerState == PAUSED && playerState == PlayerState.STARTED;
+ boolean hide = this.playerState == STARTED && playerState == PlayerState.PAUSED;
+ Util.broadcastPlaybackStatusChange(this, playerState);
+
+ this.playerState = playerState;
+ if (show) {
+ Util.showPlayingNotification(this, this, handler, currentPlaying.getSong());
+ } else if (hide) {
+ Util.hidePlayingNotification(this, this, handler);
+ }
+
+ if (playerState == STARTED) {
+ scrobbler.scrobble(this, currentPlaying, false);
+ } else if (playerState == COMPLETED) {
+ scrobbler.scrobble(this, currentPlaying, true);
+ }
+ }
+
+ @Override
+ public void setSuggestedPlaylistName(String name) {
+ this.suggestedPlaylistName = name;
+ }
+
+ @Override
+ public String getSuggestedPlaylistName() {
+ return suggestedPlaylistName;
+ }
+
+ @Override
+ public EqualizerController getEqualizerController() {
+ return equalizerController;
+ }
+
+ @Override
+ public VisualizerController getVisualizerController() {
+ return visualizerController;
+ }
+
+ @Override
+ public boolean isJukeboxEnabled() {
+ return jukeboxEnabled;
+ }
+
+ @Override
+ public void setJukeboxEnabled(boolean jukeboxEnabled) {
+ this.jukeboxEnabled = jukeboxEnabled;
+ jukeboxService.setEnabled(jukeboxEnabled);
+ if (jukeboxEnabled) {
+ reset();
+
+ // Cancel current download, if necessary.
+ if (currentDownloading != null) {
+ currentDownloading.cancelDownload();
+ }
+ }
+ }
+
+ @Override
+ public void adjustJukeboxVolume(boolean up) {
+ jukeboxService.adjustVolume(up);
+ }
+
+ private synchronized void bufferAndPlay() {
+ reset();
+
+ bufferTask = new BufferTask(currentPlaying, 0);
+ bufferTask.start();
+ }
+
+ private synchronized void doPlay(final DownloadFile downloadFile, int position, boolean start) {
+ try {
+ final File file = downloadFile.isCompleteFileAvailable() ? downloadFile.getCompleteFile() : downloadFile.getPartialFile();
+ downloadFile.updateModificationDate();
+ mediaPlayer.setOnCompletionListener(null);
+ mediaPlayer.reset();
+ setPlayerState(IDLE);
+ mediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC);
+ mediaPlayer.setDataSource(file.getPath());
+ setPlayerState(PREPARING);
+ mediaPlayer.prepare();
+ setPlayerState(PREPARED);
+
+ mediaPlayer.setOnCompletionListener(new MediaPlayer.OnCompletionListener() {
+ @Override
+ public void onCompletion(MediaPlayer mediaPlayer) {
+
+ // Acquire a temporary wakelock, since when we return from
+ // this callback the MediaPlayer will release its wakelock
+ // and allow the device to go to sleep.
+ wakeLock.acquire(60000);
+
+ setPlayerState(COMPLETED);
+
+ // If COMPLETED and not playing partial file, we are *really" finished
+ // with the song and can move on to the next.
+ if (!file.equals(downloadFile.getPartialFile())) {
+ onSongCompleted();
+ return;
+ }
+
+ // If file is not completely downloaded, restart the playback from the current position.
+ int pos = mediaPlayer.getCurrentPosition();
+ synchronized (DownloadServiceImpl.this) {
+
+ // Work-around for apparent bug on certain phones: If close (less than ten seconds) to the end
+ // of the song, skip to the next rather than restarting it.
+ Integer duration = downloadFile.getSong().getDuration() == null ? null : downloadFile.getSong().getDuration() * 1000;
+ if (duration != null) {
+ if (Math.abs(duration - pos) < 10000) {
+ Log.i(TAG, "Skipping restart from " + pos + " of " + duration);
+ onSongCompleted();
+ return;
+ }
+ }
+
+ Log.i(TAG, "Requesting restart from " + pos + " of " + duration);
+ reset();
+ bufferTask = new BufferTask(downloadFile, pos);
+ bufferTask.start();
+ }
+ }
+ });
+
+ if (position != 0) {
+ Log.i(TAG, "Restarting player from position " + position);
+ mediaPlayer.seekTo(position);
+ }
+
+ if (start) {
+ mediaPlayer.start();
+ setPlayerState(STARTED);
+ } else {
+ setPlayerState(PAUSED);
+ }
+ lifecycleSupport.serializeDownloadQueue();
+
+ } catch (Exception x) {
+ handleError(x);
+ }
+ }
+
+ private void handleError(Exception x) {
+ Log.w(TAG, "Media player error: " + x, x);
+ mediaPlayer.reset();
+ setPlayerState(IDLE);
+ }
+
+ protected synchronized void checkDownloads() {
+
+ if (!Util.isExternalStoragePresent() || !lifecycleSupport.isExternalStorageAvailable()) {
+ return;
+ }
+
+ if (shufflePlay) {
+ checkShufflePlay();
+ }
+
+ if (jukeboxEnabled || !Util.isNetworkConnected(this)) {
+ return;
+ }
+
+ if (downloadList.isEmpty()) {
+ return;
+ }
+
+ // Need to download current playing?
+ if (currentPlaying != null &&
+ currentPlaying != currentDownloading &&
+ !currentPlaying.isCompleteFileAvailable()) {
+
+ // Cancel current download, if necessary.
+ if (currentDownloading != null) {
+ currentDownloading.cancelDownload();
+ }
+
+ currentDownloading = currentPlaying;
+ currentDownloading.download();
+ cleanupCandidates.add(currentDownloading);
+ }
+
+ // Find a suitable target for download.
+ else if (currentDownloading == null || currentDownloading.isWorkDone() || currentDownloading.isFailed()) {
+
+ int n = size();
+ if (n == 0) {
+ return;
+ }
+
+ int preloaded = 0;
+
+ int start = currentPlaying == null ? 0 : getCurrentPlayingIndex();
+ int i = start;
+ do {
+ DownloadFile downloadFile = downloadList.get(i);
+ if (!downloadFile.isWorkDone()) {
+ if (downloadFile.shouldSave() || preloaded < Util.getPreloadCount(this)) {
+ currentDownloading = downloadFile;
+ currentDownloading.download();
+ cleanupCandidates.add(currentDownloading);
+ break;
+ }
+ } else if (currentPlaying != downloadFile) {
+ preloaded++;
+ }
+
+ i = (i + 1) % n;
+ } while (i != start);
+ }
+
+ // Delete obsolete .partial and .complete files.
+ cleanup();
+ }
+
+ private synchronized void checkShufflePlay() {
+
+ final int listSize = 20;
+ boolean wasEmpty = downloadList.isEmpty();
+
+ long revisionBefore = revision;
+
+ // First, ensure that list is at least 20 songs long.
+ int size = size();
+ if (size < listSize) {
+ for (MusicDirectory.Entry song : shufflePlayBuffer.get(listSize - size)) {
+ DownloadFile downloadFile = new DownloadFile(this, song, false);
+ downloadList.add(downloadFile);
+ revision++;
+ }
+ }
+
+ int currIndex = currentPlaying == null ? 0 : getCurrentPlayingIndex();
+
+ // Only shift playlist if playing song #5 or later.
+ if (currIndex > 4) {
+ int songsToShift = currIndex - 2;
+ for (MusicDirectory.Entry song : shufflePlayBuffer.get(songsToShift)) {
+ downloadList.add(new DownloadFile(this, song, false));
+ downloadList.get(0).cancelDownload();
+ downloadList.remove(0);
+ revision++;
+ }
+ }
+
+ if (revisionBefore != revision) {
+ updateJukeboxPlaylist();
+ }
+
+ if (wasEmpty && !downloadList.isEmpty()) {
+ play(0);
+ }
+ }
+
+ public long getDownloadListUpdateRevision() {
+ return revision;
+ }
+
+ private synchronized void cleanup() {
+ Iterator<DownloadFile> iterator = cleanupCandidates.iterator();
+ while (iterator.hasNext()) {
+ DownloadFile downloadFile = iterator.next();
+ if (downloadFile != currentPlaying && downloadFile != currentDownloading) {
+ if (downloadFile.cleanup()) {
+ iterator.remove();
+ }
+ }
+ }
+ }
+
+ private class BufferTask extends CancellableTask {
+
+ private static final int BUFFER_LENGTH_SECONDS = 5;
+
+ private final DownloadFile downloadFile;
+ private final int position;
+ private final long expectedFileSize;
+ private final File partialFile;
+
+ public BufferTask(DownloadFile downloadFile, int position) {
+ this.downloadFile = downloadFile;
+ this.position = position;
+ partialFile = downloadFile.getPartialFile();
+
+ // Calculate roughly how many bytes BUFFER_LENGTH_SECONDS corresponds to.
+ int bitRate = downloadFile.getBitRate();
+ long byteCount = Math.max(100000, bitRate * 1024 / 8 * BUFFER_LENGTH_SECONDS);
+
+ // Find out how large the file should grow before resuming playback.
+ expectedFileSize = partialFile.length() + byteCount;
+ }
+
+ @Override
+ public void execute() {
+ setPlayerState(DOWNLOADING);
+
+ while (!bufferComplete()) {
+ Util.sleepQuietly(1000L);
+ if (isCancelled()) {
+ return;
+ }
+ }
+ doPlay(downloadFile, position, true);
+ }
+
+ private boolean bufferComplete() {
+ boolean completeFileAvailable = downloadFile.isCompleteFileAvailable();
+ long size = partialFile.length();
+
+ Log.i(TAG, "Buffering " + partialFile + " (" + size + "/" + expectedFileSize + ", " + completeFileAvailable + ")");
+ return completeFileAvailable || size >= expectedFileSize;
+ }
+
+ @Override
+ public String toString() {
+ return "BufferTask (" + downloadFile + ")";
+ }
+ }
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/DownloadServiceLifecycleSupport.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/DownloadServiceLifecycleSupport.java
new file mode 100644
index 00000000..2010a4a1
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/DownloadServiceLifecycleSupport.java
@@ -0,0 +1,266 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.service;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.telephony.PhoneStateListener;
+import android.telephony.TelephonyManager;
+import android.util.Log;
+import android.view.KeyEvent;
+import net.sourceforge.subsonic.androidapp.domain.MusicDirectory;
+import net.sourceforge.subsonic.androidapp.domain.PlayerState;
+import net.sourceforge.subsonic.androidapp.util.CacheCleaner;
+import net.sourceforge.subsonic.androidapp.util.FileUtil;
+import net.sourceforge.subsonic.androidapp.util.Util;
+
+/**
+ * @author Sindre Mehus
+ */
+public class DownloadServiceLifecycleSupport {
+
+ private static final String TAG = DownloadServiceLifecycleSupport.class.getSimpleName();
+ private static final String FILENAME_DOWNLOADS_SER = "downloadstate.ser";
+
+ private final DownloadServiceImpl downloadService;
+ private ScheduledExecutorService executorService;
+ private BroadcastReceiver headsetEventReceiver;
+ private BroadcastReceiver ejectEventReceiver;
+ private PhoneStateListener phoneStateListener;
+ private boolean externalStorageAvailable= true;
+
+ /**
+ * This receiver manages the intent that could come from other applications.
+ */
+ private BroadcastReceiver intentReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+ Log.i(TAG, "intentReceiver.onReceive: " + action);
+ if (DownloadServiceImpl.CMD_PLAY.equals(action)) {
+ downloadService.play();
+ } else if (DownloadServiceImpl.CMD_NEXT.equals(action)) {
+ downloadService.next();
+ } else if (DownloadServiceImpl.CMD_PREVIOUS.equals(action)) {
+ downloadService.previous();
+ } else if (DownloadServiceImpl.CMD_TOGGLEPAUSE.equals(action)) {
+ downloadService.togglePlayPause();
+ } else if (DownloadServiceImpl.CMD_PAUSE.equals(action)) {
+ downloadService.pause();
+ } else if (DownloadServiceImpl.CMD_STOP.equals(action)) {
+ downloadService.pause();
+ downloadService.seekTo(0);
+ }
+ }
+ };
+
+
+ public DownloadServiceLifecycleSupport(DownloadServiceImpl downloadService) {
+ this.downloadService = downloadService;
+ }
+
+ public void onCreate() {
+ Runnable downloadChecker = new Runnable() {
+ @Override
+ public void run() {
+ try {
+ downloadService.checkDownloads();
+ } catch (Throwable x) {
+ Log.e(TAG, "checkDownloads() failed.", x);
+ }
+ }
+ };
+
+ executorService = Executors.newScheduledThreadPool(2);
+ executorService.scheduleWithFixedDelay(downloadChecker, 5, 5, TimeUnit.SECONDS);
+
+ // Pause when headset is unplugged.
+ headsetEventReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ Log.i(TAG, "Headset event for: " + intent.getExtras().get("name"));
+ if (intent.getExtras().getInt("state") == 0) {
+ downloadService.pause();
+ }
+ }
+ };
+ downloadService.registerReceiver(headsetEventReceiver, new IntentFilter(Intent.ACTION_HEADSET_PLUG));
+
+ // Stop when SD card is ejected.
+ ejectEventReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ externalStorageAvailable = Intent.ACTION_MEDIA_MOUNTED.equals(intent.getAction());
+ if (!externalStorageAvailable) {
+ Log.i(TAG, "External media is ejecting. Stopping playback.");
+ downloadService.reset();
+ } else {
+ Log.i(TAG, "External media is available.");
+ }
+ }
+ };
+ IntentFilter ejectFilter = new IntentFilter(Intent.ACTION_MEDIA_EJECT);
+ ejectFilter.addAction(Intent.ACTION_MEDIA_MOUNTED);
+ ejectFilter.addDataScheme("file");
+ downloadService.registerReceiver(ejectEventReceiver, ejectFilter);
+
+ // React to media buttons.
+ Util.registerMediaButtonEventReceiver(downloadService);
+
+ // Pause temporarily on incoming phone calls.
+ phoneStateListener = new MyPhoneStateListener();
+ TelephonyManager telephonyManager = (TelephonyManager) downloadService.getSystemService(Context.TELEPHONY_SERVICE);
+ telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_CALL_STATE);
+
+ // Register the handler for outside intents.
+ IntentFilter commandFilter = new IntentFilter();
+ commandFilter.addAction(DownloadServiceImpl.CMD_PLAY);
+ commandFilter.addAction(DownloadServiceImpl.CMD_TOGGLEPAUSE);
+ commandFilter.addAction(DownloadServiceImpl.CMD_PAUSE);
+ commandFilter.addAction(DownloadServiceImpl.CMD_STOP);
+ commandFilter.addAction(DownloadServiceImpl.CMD_PREVIOUS);
+ commandFilter.addAction(DownloadServiceImpl.CMD_NEXT);
+ downloadService.registerReceiver(intentReceiver, commandFilter);
+
+ deserializeDownloadQueue();
+
+ new CacheCleaner(downloadService, downloadService).clean();
+ }
+
+ public void onStart(Intent intent) {
+ if (intent != null && intent.getExtras() != null) {
+ KeyEvent event = (KeyEvent) intent.getExtras().get(Intent.EXTRA_KEY_EVENT);
+ if (event != null) {
+ handleKeyEvent(event);
+ }
+ }
+ }
+
+ public void onDestroy() {
+ executorService.shutdown();
+ serializeDownloadQueue();
+ downloadService.clear(false);
+ downloadService.unregisterReceiver(ejectEventReceiver);
+ downloadService.unregisterReceiver(headsetEventReceiver);
+ downloadService.unregisterReceiver(intentReceiver);
+
+ TelephonyManager telephonyManager = (TelephonyManager) downloadService.getSystemService(Context.TELEPHONY_SERVICE);
+ telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_NONE);
+ }
+
+ public boolean isExternalStorageAvailable() {
+ return externalStorageAvailable;
+ }
+
+ public void serializeDownloadQueue() {
+ State state = new State();
+ for (DownloadFile downloadFile : downloadService.getDownloads()) {
+ state.songs.add(downloadFile.getSong());
+ }
+ state.currentPlayingIndex = downloadService.getCurrentPlayingIndex();
+ state.currentPlayingPosition = downloadService.getPlayerPosition();
+
+ Log.i(TAG, "Serialized currentPlayingIndex: " + state.currentPlayingIndex + ", currentPlayingPosition: " + state.currentPlayingPosition);
+ FileUtil.serialize(downloadService, state, FILENAME_DOWNLOADS_SER);
+ }
+
+ private void deserializeDownloadQueue() {
+ State state = FileUtil.deserialize(downloadService, FILENAME_DOWNLOADS_SER);
+ if (state == null) {
+ return;
+ }
+ Log.i(TAG, "Deserialized currentPlayingIndex: " + state.currentPlayingIndex + ", currentPlayingPosition: " + state.currentPlayingPosition);
+ downloadService.restore(state.songs, state.currentPlayingIndex, state.currentPlayingPosition);
+
+ // Work-around: Serialize again, as the restore() method creates a serialization without current playing info.
+ serializeDownloadQueue();
+ }
+
+ private void handleKeyEvent(KeyEvent event) {
+ if (event.getAction() != KeyEvent.ACTION_DOWN || event.getRepeatCount() > 0) {
+ return;
+ }
+
+ switch (event.getKeyCode()) {
+ case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE:
+ case KeyEvent.KEYCODE_HEADSETHOOK:
+ downloadService.togglePlayPause();
+ break;
+ case KeyEvent.KEYCODE_MEDIA_PREVIOUS:
+ downloadService.previous();
+ break;
+ case KeyEvent.KEYCODE_MEDIA_NEXT:
+ if (downloadService.getCurrentPlayingIndex() < downloadService.size() - 1) {
+ downloadService.next();
+ }
+ break;
+ case KeyEvent.KEYCODE_MEDIA_STOP:
+ downloadService.reset();
+ break;
+ default:
+ break;
+ }
+ }
+
+ /**
+ * Logic taken from packages/apps/Music. Will pause when an incoming
+ * call rings or if a call (incoming or outgoing) is connected.
+ */
+ private class MyPhoneStateListener extends PhoneStateListener {
+ private boolean resumeAfterCall;
+
+ @Override
+ public void onCallStateChanged(int state, String incomingNumber) {
+ switch (state) {
+ case TelephonyManager.CALL_STATE_RINGING:
+ case TelephonyManager.CALL_STATE_OFFHOOK:
+ if (downloadService.getPlayerState() == PlayerState.STARTED) {
+ resumeAfterCall = true;
+ downloadService.pause();
+ }
+ break;
+ case TelephonyManager.CALL_STATE_IDLE:
+ if (resumeAfterCall) {
+ resumeAfterCall = false;
+ downloadService.start();
+ }
+ break;
+ default:
+ break;
+ }
+ }
+ }
+
+ private static class State implements Serializable {
+ private static final long serialVersionUID = -6346438781062572270L;
+
+ private List<MusicDirectory.Entry> songs = new ArrayList<MusicDirectory.Entry>();
+ private int currentPlayingIndex;
+ private int currentPlayingPosition;
+ }
+} \ No newline at end of file
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/JukeboxService.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/JukeboxService.java
new file mode 100644
index 00000000..e3145f4e
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/JukeboxService.java
@@ -0,0 +1,356 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.service;
+
+import android.content.Context;
+import android.os.Handler;
+import android.util.Log;
+import android.view.Gravity;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.ProgressBar;
+import android.widget.Toast;
+import net.sourceforge.subsonic.androidapp.R;
+import net.sourceforge.subsonic.androidapp.domain.JukeboxStatus;
+import net.sourceforge.subsonic.androidapp.domain.PlayerState;
+import net.sourceforge.subsonic.androidapp.service.parser.SubsonicRESTException;
+import net.sourceforge.subsonic.androidapp.util.Util;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.concurrent.Executors;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * Provides an asynchronous interface to the remote jukebox on the Subsonic server.
+ *
+ * @author Sindre Mehus
+ * @version $Id$
+ */
+public class JukeboxService {
+
+ private static final String TAG = JukeboxService.class.getSimpleName();
+ private static final long STATUS_UPDATE_INTERVAL_SECONDS = 5L;
+
+ private final Handler handler = new Handler();
+ private final TaskQueue tasks = new TaskQueue();
+ private final DownloadServiceImpl downloadService;
+ private final ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor();
+ private ScheduledFuture<?> statusUpdateFuture;
+ private final AtomicLong timeOfLastUpdate = new AtomicLong();
+ private JukeboxStatus jukeboxStatus;
+ private float gain = 0.5f;
+ private VolumeToast volumeToast;
+
+ // TODO: Report warning if queue fills up.
+ // TODO: Create shutdown method?
+ // TODO: Disable repeat.
+ // TODO: Persist RC state?
+ // TODO: Minimize status updates.
+
+ public JukeboxService(DownloadServiceImpl downloadService) {
+ this.downloadService = downloadService;
+ new Thread() {
+ @Override
+ public void run() {
+ processTasks();
+ }
+ }.start();
+ }
+
+ private synchronized void startStatusUpdate() {
+ stopStatusUpdate();
+ Runnable updateTask = new Runnable() {
+ @Override
+ public void run() {
+ tasks.remove(GetStatus.class);
+ tasks.add(new GetStatus());
+ }
+ };
+ statusUpdateFuture = executorService.scheduleWithFixedDelay(updateTask, STATUS_UPDATE_INTERVAL_SECONDS,
+ STATUS_UPDATE_INTERVAL_SECONDS, TimeUnit.SECONDS);
+ }
+
+ private synchronized void stopStatusUpdate() {
+ if (statusUpdateFuture != null) {
+ statusUpdateFuture.cancel(false);
+ statusUpdateFuture = null;
+ }
+ }
+
+ private void processTasks() {
+ while (true) {
+ JukeboxTask task = null;
+ try {
+ task = tasks.take();
+ JukeboxStatus status = task.execute();
+ onStatusUpdate(status);
+ } catch (Throwable x) {
+ onError(task, x);
+ }
+ }
+ }
+
+ private void onStatusUpdate(JukeboxStatus jukeboxStatus) {
+ timeOfLastUpdate.set(System.currentTimeMillis());
+ this.jukeboxStatus = jukeboxStatus;
+
+ // Track change?
+ Integer index = jukeboxStatus.getCurrentPlayingIndex();
+ if (index != null && index != -1 && index != downloadService.getCurrentPlayingIndex()) {
+ downloadService.setCurrentPlaying(index, true);
+ }
+ }
+
+ private void onError(JukeboxTask task, Throwable x) {
+ if (x instanceof ServerTooOldException && !(task instanceof Stop)) {
+ disableJukeboxOnError(x, R.string.download_jukebox_server_too_old);
+ } else if (x instanceof OfflineException && !(task instanceof Stop)) {
+ disableJukeboxOnError(x, R.string.download_jukebox_offline);
+ } else if (x instanceof SubsonicRESTException && ((SubsonicRESTException) x).getCode() == 50 && !(task instanceof Stop)) {
+ disableJukeboxOnError(x, R.string.download_jukebox_not_authorized);
+ } else {
+ Log.e(TAG, "Failed to process jukebox task: " + x, x);
+ }
+ }
+
+ private void disableJukeboxOnError(Throwable x, final int resourceId) {
+ Log.w(TAG, x.toString());
+ handler.post(new Runnable() {
+ @Override
+ public void run() {
+ Util.toast(downloadService, resourceId, false);
+ }
+ });
+ downloadService.setJukeboxEnabled(false);
+ }
+
+ public void updatePlaylist() {
+ tasks.remove(Skip.class);
+ tasks.remove(Stop.class);
+ tasks.remove(Start.class);
+
+ List<String> ids = new ArrayList<String>();
+ for (DownloadFile file : downloadService.getDownloads()) {
+ ids.add(file.getSong().getId());
+ }
+ tasks.add(new SetPlaylist(ids));
+ }
+
+ public void skip(final int index, final int offsetSeconds) {
+ tasks.remove(Skip.class);
+ tasks.remove(Stop.class);
+ tasks.remove(Start.class);
+
+ startStatusUpdate();
+ if (jukeboxStatus != null) {
+ jukeboxStatus.setPositionSeconds(offsetSeconds);
+ }
+ tasks.add(new Skip(index, offsetSeconds));
+ downloadService.setPlayerState(PlayerState.STARTED);
+ }
+
+ public void stop() {
+ tasks.remove(Stop.class);
+ tasks.remove(Start.class);
+
+ stopStatusUpdate();
+ tasks.add(new Stop());
+ }
+
+ public void start() {
+ tasks.remove(Stop.class);
+ tasks.remove(Start.class);
+
+ startStatusUpdate();
+ tasks.add(new Start());
+ }
+
+ public synchronized void adjustVolume(boolean up) {
+ float delta = up ? 0.1f : -0.1f;
+ gain += delta;
+ gain = Math.max(gain, 0.0f);
+ gain = Math.min(gain, 1.0f);
+
+ tasks.remove(SetGain.class);
+ tasks.add(new SetGain(gain));
+
+ if (volumeToast == null) {
+ volumeToast = new VolumeToast(downloadService);
+ }
+ volumeToast.setVolume(gain);
+ }
+
+ private MusicService getMusicService() {
+ return MusicServiceFactory.getMusicService(downloadService);
+ }
+
+ public int getPositionSeconds() {
+ if (jukeboxStatus == null || jukeboxStatus.getPositionSeconds() == null || timeOfLastUpdate.get() == 0) {
+ return 0;
+ }
+
+ if (jukeboxStatus.isPlaying()) {
+ int secondsSinceLastUpdate = (int) ((System.currentTimeMillis() - timeOfLastUpdate.get()) / 1000L);
+ return jukeboxStatus.getPositionSeconds() + secondsSinceLastUpdate;
+ }
+
+ return jukeboxStatus.getPositionSeconds();
+ }
+
+ public void setEnabled(boolean enabled) {
+ tasks.clear();
+ if (enabled) {
+ updatePlaylist();
+ }
+ stop();
+ downloadService.setPlayerState(PlayerState.IDLE);
+ }
+
+ private static class TaskQueue {
+
+ private final LinkedBlockingQueue<JukeboxTask> queue = new LinkedBlockingQueue<JukeboxTask>();
+
+ void add(JukeboxTask jukeboxTask) {
+ queue.add(jukeboxTask);
+ }
+
+ JukeboxTask take() throws InterruptedException {
+ return queue.take();
+ }
+
+ void remove(Class<? extends JukeboxTask> clazz) {
+ try {
+ Iterator<JukeboxTask> iterator = queue.iterator();
+ while (iterator.hasNext()) {
+ JukeboxTask task = iterator.next();
+ if (clazz.equals(task.getClass())) {
+ iterator.remove();
+ }
+ }
+ } catch (Throwable x) {
+ Log.w(TAG, "Failed to clean-up task queue.", x);
+ }
+ }
+
+ void clear() {
+ queue.clear();
+ }
+ }
+
+ private abstract class JukeboxTask {
+
+ abstract JukeboxStatus execute() throws Exception;
+
+ @Override
+ public String toString() {
+ return getClass().getSimpleName();
+ }
+ }
+
+ private class GetStatus extends JukeboxTask {
+ @Override
+ JukeboxStatus execute() throws Exception {
+ return getMusicService().getJukeboxStatus(downloadService, null);
+ }
+ }
+
+ private class SetPlaylist extends JukeboxTask {
+
+ private final List<String> ids;
+
+ SetPlaylist(List<String> ids) {
+ this.ids = ids;
+ }
+
+ @Override
+ JukeboxStatus execute() throws Exception {
+ return getMusicService().updateJukeboxPlaylist(ids, downloadService, null);
+ }
+ }
+
+ private class Skip extends JukeboxTask {
+ private final int index;
+ private final int offsetSeconds;
+
+ Skip(int index, int offsetSeconds) {
+ this.index = index;
+ this.offsetSeconds = offsetSeconds;
+ }
+
+ @Override
+ JukeboxStatus execute() throws Exception {
+ return getMusicService().skipJukebox(index, offsetSeconds, downloadService, null);
+ }
+ }
+
+ private class Stop extends JukeboxTask {
+ @Override
+ JukeboxStatus execute() throws Exception {
+ return getMusicService().stopJukebox(downloadService, null);
+ }
+ }
+
+ private class Start extends JukeboxTask {
+ @Override
+ JukeboxStatus execute() throws Exception {
+ return getMusicService().startJukebox(downloadService, null);
+ }
+ }
+
+ private class SetGain extends JukeboxTask {
+
+ private final float gain;
+
+ private SetGain(float gain) {
+ this.gain = gain;
+ }
+
+ @Override
+ JukeboxStatus execute() throws Exception {
+ return getMusicService().setJukeboxGain(gain, downloadService, null);
+ }
+ }
+
+ private static class VolumeToast extends Toast {
+
+ private final ProgressBar progressBar;
+
+ public VolumeToast(Context context) {
+ super(context);
+ setDuration(Toast.LENGTH_SHORT);
+ LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ View view = inflater.inflate(R.layout.jukebox_volume, null);
+ progressBar = (ProgressBar) view.findViewById(R.id.jukebox_volume_progress_bar);
+
+ setView(view);
+ setGravity(Gravity.TOP, 0, 0);
+ }
+
+ public void setVolume(float volume) {
+ progressBar.setProgress(Math.round(100 * volume));
+ show();
+ }
+ }
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/MediaStoreService.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/MediaStoreService.java
new file mode 100644
index 00000000..775fa3f5
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/MediaStoreService.java
@@ -0,0 +1,109 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.service;
+
+import java.io.File;
+
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.MediaStore;
+import android.util.Log;
+import net.sourceforge.subsonic.androidapp.domain.MusicDirectory;
+import net.sourceforge.subsonic.androidapp.util.FileUtil;
+
+/**
+ * @author Sindre Mehus
+ */
+public class MediaStoreService {
+
+ private static final String TAG = MediaStoreService.class.getSimpleName();
+ private static final Uri ALBUM_ART_URI = Uri.parse("content://media/external/audio/albumart");
+
+ private final Context context;
+
+ public MediaStoreService(Context context) {
+ this.context = context;
+ }
+
+ public void saveInMediaStore(DownloadFile downloadFile) {
+ MusicDirectory.Entry song = downloadFile.getSong();
+ File songFile = downloadFile.getCompleteFile();
+
+ // Delete existing row in case the song has been downloaded before.
+ deleteFromMediaStore(downloadFile);
+
+ ContentResolver contentResolver = context.getContentResolver();
+ ContentValues values = new ContentValues();
+ values.put(MediaStore.MediaColumns.TITLE, song.getTitle());
+ values.put(MediaStore.Audio.AudioColumns.ARTIST, song.getArtist());
+ values.put(MediaStore.Audio.AudioColumns.ALBUM, song.getAlbum());
+ values.put(MediaStore.Audio.AudioColumns.TRACK, song.getTrack());
+ values.put(MediaStore.Audio.AudioColumns.YEAR, song.getYear());
+ values.put(MediaStore.MediaColumns.DATA, songFile.getAbsolutePath());
+ values.put(MediaStore.MediaColumns.MIME_TYPE, song.getContentType());
+ values.put(MediaStore.Audio.AudioColumns.IS_MUSIC, 1);
+
+ Uri uri = contentResolver.insert(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, values);
+
+ // Look up album, and add cover art if found.
+ Cursor cursor = contentResolver.query(uri, new String[]{MediaStore.Audio.AudioColumns.ALBUM_ID}, null, null, null);
+ if (cursor.moveToFirst()) {
+ int albumId = cursor.getInt(0);
+ insertAlbumArt(albumId, downloadFile);
+ }
+ cursor.close();
+ }
+
+ public void deleteFromMediaStore(DownloadFile downloadFile) {
+ ContentResolver contentResolver = context.getContentResolver();
+ MusicDirectory.Entry song = downloadFile.getSong();
+ File file = downloadFile.getCompleteFile();
+
+ int n = contentResolver.delete(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
+ MediaStore.Audio.AudioColumns.TITLE_KEY + "=? AND " +
+ MediaStore.MediaColumns.DATA + "=?",
+ new String[]{MediaStore.Audio.keyFor(song.getTitle()), file.getAbsolutePath()});
+ if (n > 0) {
+ Log.i(TAG, "Deleting media store row for " + song);
+ }
+ }
+
+ private void insertAlbumArt(int albumId, DownloadFile downloadFile) {
+ ContentResolver contentResolver = context.getContentResolver();
+
+ Cursor cursor = contentResolver.query(Uri.withAppendedPath(ALBUM_ART_URI, String.valueOf(albumId)), null, null, null, null);
+ if (!cursor.moveToFirst()) {
+
+ // No album art found, add it.
+ File albumArtFile = FileUtil.getAlbumArtFile(context, downloadFile.getSong());
+ if (albumArtFile.exists()) {
+ ContentValues values = new ContentValues();
+ values.put(MediaStore.Audio.AlbumColumns.ALBUM_ID, albumId);
+ values.put(MediaStore.MediaColumns.DATA, albumArtFile.getPath());
+ contentResolver.insert(ALBUM_ART_URI, values);
+ Log.i(TAG, "Added album art: " + albumArtFile);
+ }
+ }
+ cursor.close();
+ }
+
+} \ No newline at end of file
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/MusicService.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/MusicService.java
new file mode 100644
index 00000000..2acb4c65
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/MusicService.java
@@ -0,0 +1,91 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.service;
+
+import java.util.List;
+
+import org.apache.http.HttpResponse;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import net.sourceforge.subsonic.androidapp.domain.Indexes;
+import net.sourceforge.subsonic.androidapp.domain.JukeboxStatus;
+import net.sourceforge.subsonic.androidapp.domain.Lyrics;
+import net.sourceforge.subsonic.androidapp.domain.MusicDirectory;
+import net.sourceforge.subsonic.androidapp.domain.MusicFolder;
+import net.sourceforge.subsonic.androidapp.domain.Playlist;
+import net.sourceforge.subsonic.androidapp.domain.SearchCritera;
+import net.sourceforge.subsonic.androidapp.domain.SearchResult;
+import net.sourceforge.subsonic.androidapp.domain.Version;
+import net.sourceforge.subsonic.androidapp.util.CancellableTask;
+import net.sourceforge.subsonic.androidapp.util.ProgressListener;
+
+/**
+ * @author Sindre Mehus
+ */
+public interface MusicService {
+
+ void ping(Context context, ProgressListener progressListener) throws Exception;
+
+ boolean isLicenseValid(Context context, ProgressListener progressListener) throws Exception;
+
+ List<MusicFolder> getMusicFolders(boolean refresh, Context context, ProgressListener progressListener) throws Exception;
+
+ Indexes getIndexes(String musicFolderId, boolean refresh, Context context, ProgressListener progressListener) throws Exception;
+
+ MusicDirectory getMusicDirectory(String id, boolean refresh, Context context, ProgressListener progressListener) throws Exception;
+
+ SearchResult search(SearchCritera criteria, Context context, ProgressListener progressListener) throws Exception;
+
+ MusicDirectory getPlaylist(String id, Context context, ProgressListener progressListener) throws Exception;
+
+ List<Playlist> getPlaylists(boolean refresh, Context context, ProgressListener progressListener) throws Exception;
+
+ void createPlaylist(String id, String name, List<MusicDirectory.Entry> entries, Context context, ProgressListener progressListener) throws Exception;
+
+ Lyrics getLyrics(String artist, String title, Context context, ProgressListener progressListener) throws Exception;
+
+ void scrobble(String id, boolean submission, Context context, ProgressListener progressListener) throws Exception;
+
+ MusicDirectory getAlbumList(String type, int size, int offset, Context context, ProgressListener progressListener) throws Exception;
+
+ MusicDirectory getRandomSongs(int size, Context context, ProgressListener progressListener) throws Exception;
+
+ Bitmap getCoverArt(Context context, MusicDirectory.Entry entry, int size, boolean saveToFile, ProgressListener progressListener) throws Exception;
+
+ HttpResponse getDownloadInputStream(Context context, MusicDirectory.Entry song, long offset, int maxBitrate, CancellableTask task) throws Exception;
+
+ Version getLocalVersion(Context context) throws Exception;
+
+ Version getLatestVersion(Context context, ProgressListener progressListener) throws Exception;
+
+ String getVideoUrl(Context context, String id);
+
+ JukeboxStatus updateJukeboxPlaylist(List<String> ids, Context context, ProgressListener progressListener) throws Exception;
+
+ JukeboxStatus skipJukebox(int index, int offsetSeconds, Context context, ProgressListener progressListener) throws Exception;
+
+ JukeboxStatus stopJukebox(Context context, ProgressListener progressListener) throws Exception;
+
+ JukeboxStatus startJukebox(Context context, ProgressListener progressListener) throws Exception;
+
+ JukeboxStatus getJukeboxStatus(Context context, ProgressListener progressListener) throws Exception;
+
+ JukeboxStatus setJukeboxGain(float gain, Context context, ProgressListener progressListener) throws Exception;
+} \ No newline at end of file
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/MusicServiceFactory.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/MusicServiceFactory.java
new file mode 100644
index 00000000..552d1d32
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/MusicServiceFactory.java
@@ -0,0 +1,36 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.service;
+
+import android.content.Context;
+import net.sourceforge.subsonic.androidapp.util.Util;
+
+/**
+ * @author Sindre Mehus
+ * @version $Id$
+ */
+public class MusicServiceFactory {
+
+ private static final MusicService REST_MUSIC_SERVICE = new CachedMusicService(new RESTMusicService());
+ private static final MusicService OFFLINE_MUSIC_SERVICE = new OfflineMusicService();
+
+ public static MusicService getMusicService(Context context) {
+ return Util.isOffline(context) ? OFFLINE_MUSIC_SERVICE : REST_MUSIC_SERVICE;
+ }
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/OfflineException.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/OfflineException.java
new file mode 100644
index 00000000..49c000bf
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/OfflineException.java
@@ -0,0 +1,32 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.service;
+
+/**
+ * Thrown by service methods that are not available in offline mode.
+ *
+ * @author Sindre Mehus
+ * @version $Id$
+ */
+public class OfflineException extends Exception {
+
+ public OfflineException(String message) {
+ super(message);
+ }
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/OfflineMusicService.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/OfflineMusicService.java
new file mode 100644
index 00000000..6a8ad6d0
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/OfflineMusicService.java
@@ -0,0 +1,244 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.service;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Random;
+import java.util.Set;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import net.sourceforge.subsonic.androidapp.domain.Artist;
+import net.sourceforge.subsonic.androidapp.domain.Indexes;
+import net.sourceforge.subsonic.androidapp.domain.JukeboxStatus;
+import net.sourceforge.subsonic.androidapp.domain.Lyrics;
+import net.sourceforge.subsonic.androidapp.domain.MusicDirectory;
+import net.sourceforge.subsonic.androidapp.domain.MusicFolder;
+import net.sourceforge.subsonic.androidapp.domain.Playlist;
+import net.sourceforge.subsonic.androidapp.domain.SearchCritera;
+import net.sourceforge.subsonic.androidapp.domain.SearchResult;
+import net.sourceforge.subsonic.androidapp.util.Constants;
+import net.sourceforge.subsonic.androidapp.util.FileUtil;
+import net.sourceforge.subsonic.androidapp.util.ProgressListener;
+import net.sourceforge.subsonic.androidapp.util.Util;
+
+/**
+ * @author Sindre Mehus
+ */
+public class OfflineMusicService extends RESTMusicService {
+
+ @Override
+ public boolean isLicenseValid(Context context, ProgressListener progressListener) throws Exception {
+ return true;
+ }
+
+ @Override
+ public Indexes getIndexes(String musicFolderId, boolean refresh, Context context, ProgressListener progressListener) throws Exception {
+ List<Artist> artists = new ArrayList<Artist>();
+ File root = FileUtil.getMusicDirectory(context);
+ for (File file : FileUtil.listFiles(root)) {
+ if (file.isDirectory()) {
+ Artist artist = new Artist();
+ artist.setId(file.getPath());
+ artist.setIndex(file.getName().substring(0, 1));
+ artist.setName(file.getName());
+ artists.add(artist);
+ }
+ }
+ return new Indexes(0L, Collections.<Artist>emptyList(), artists);
+ }
+
+ @Override
+ public MusicDirectory getMusicDirectory(String id, boolean refresh, Context context, ProgressListener progressListener) throws Exception {
+ File dir = new File(id);
+ MusicDirectory result = new MusicDirectory();
+ result.setName(dir.getName());
+
+ Set<String> names = new HashSet<String>();
+
+ for (File file : FileUtil.listMusicFiles(dir)) {
+ String name = getName(file);
+ if (name != null & !names.contains(name)) {
+ names.add(name);
+ result.addChild(createEntry(context, file, name));
+ }
+ }
+ return result;
+ }
+
+ private String getName(File file) {
+ String name = file.getName();
+ if (file.isDirectory()) {
+ return name;
+ }
+
+ if (name.endsWith(".partial") || name.contains(".partial.") || name.equals(Constants.ALBUM_ART_FILE)) {
+ return null;
+ }
+
+ name = name.replace(".complete", "");
+ return FileUtil.getBaseName(name);
+ }
+
+ private MusicDirectory.Entry createEntry(Context context, File file, String name) {
+ MusicDirectory.Entry entry = new MusicDirectory.Entry();
+ entry.setDirectory(file.isDirectory());
+ entry.setId(file.getPath());
+ entry.setParent(file.getParent());
+ entry.setSize(file.length());
+ String root = FileUtil.getMusicDirectory(context).getPath();
+ entry.setPath(file.getPath().replaceFirst("^" + root + "/" , ""));
+ if (file.isFile()) {
+ entry.setArtist(file.getParentFile().getParentFile().getName());
+ entry.setAlbum(file.getParentFile().getName());
+ }
+ entry.setTitle(name);
+ entry.setSuffix(FileUtil.getExtension(file.getName().replace(".complete", "")));
+
+ File albumArt = FileUtil.getAlbumArtFile(context, entry);
+ if (albumArt.exists()) {
+ entry.setCoverArt(albumArt.getPath());
+ }
+ return entry;
+ }
+
+ @Override
+ public Bitmap getCoverArt(Context context, MusicDirectory.Entry entry, int size, boolean saveToFile, ProgressListener progressListener) throws Exception {
+ InputStream in = new FileInputStream(entry.getCoverArt());
+ try {
+ byte[] bytes = Util.toByteArray(in);
+ Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
+ return Bitmap.createScaledBitmap(bitmap, size, size, true);
+ } finally {
+ Util.close(in);
+ }
+ }
+
+ @Override
+ public List<MusicFolder> getMusicFolders(boolean refresh, Context context, ProgressListener progressListener) throws Exception {
+ throw new OfflineException("Music folders not available in offline mode");
+ }
+
+ @Override
+ public SearchResult search(SearchCritera criteria, Context context, ProgressListener progressListener) throws Exception {
+ throw new OfflineException("Search not available in offline mode");
+ }
+
+ @Override
+ public List<Playlist> getPlaylists(boolean refresh, Context context, ProgressListener progressListener) throws Exception {
+ throw new OfflineException("Playlists not available in offline mode");
+ }
+
+ @Override
+ public MusicDirectory getPlaylist(String id, Context context, ProgressListener progressListener) throws Exception {
+ throw new OfflineException("Playlists not available in offline mode");
+ }
+
+ @Override
+ public void createPlaylist(String id, String name, List<MusicDirectory.Entry> entries, Context context, ProgressListener progressListener) throws Exception {
+ throw new OfflineException("Playlists not available in offline mode");
+ }
+
+ @Override
+ public Lyrics getLyrics(String artist, String title, Context context, ProgressListener progressListener) throws Exception {
+ throw new OfflineException("Lyrics not available in offline mode");
+ }
+
+ @Override
+ public void scrobble(String id, boolean submission, Context context, ProgressListener progressListener) throws Exception {
+ throw new OfflineException("Scrobbling not available in offline mode");
+ }
+
+ @Override
+ public MusicDirectory getAlbumList(String type, int size, int offset, Context context, ProgressListener progressListener) throws Exception {
+ throw new OfflineException("Album lists not available in offline mode");
+ }
+
+ @Override
+ public String getVideoUrl(Context context, String id) {
+ return null;
+ }
+
+ @Override
+ public JukeboxStatus updateJukeboxPlaylist(List<String> ids, Context context, ProgressListener progressListener) throws Exception {
+ throw new OfflineException("Jukebox not available in offline mode");
+ }
+
+ @Override
+ public JukeboxStatus skipJukebox(int index, int offsetSeconds, Context context, ProgressListener progressListener) throws Exception {
+ throw new OfflineException("Jukebox not available in offline mode");
+ }
+
+ @Override
+ public JukeboxStatus stopJukebox(Context context, ProgressListener progressListener) throws Exception {
+ throw new OfflineException("Jukebox not available in offline mode");
+ }
+
+ @Override
+ public JukeboxStatus startJukebox(Context context, ProgressListener progressListener) throws Exception {
+ throw new OfflineException("Jukebox not available in offline mode");
+ }
+
+ @Override
+ public JukeboxStatus getJukeboxStatus(Context context, ProgressListener progressListener) throws Exception {
+ throw new OfflineException("Jukebox not available in offline mode");
+ }
+
+ @Override
+ public JukeboxStatus setJukeboxGain(float gain, Context context, ProgressListener progressListener) throws Exception {
+ throw new OfflineException("Jukebox not available in offline mode");
+ }
+
+ @Override
+ public MusicDirectory getRandomSongs(int size, Context context, ProgressListener progressListener) throws Exception {
+ File root = FileUtil.getMusicDirectory(context);
+ List<File> children = new LinkedList<File>();
+ listFilesRecursively(root, children);
+ MusicDirectory result = new MusicDirectory();
+
+ if (children.isEmpty()) {
+ return result;
+ }
+ Random random = new Random();
+ for (int i = 0; i < size; i++) {
+ File file = children.get(random.nextInt(children.size()));
+ result.addChild(createEntry(context, file, getName(file)));
+ }
+
+ return result;
+ }
+
+ private void listFilesRecursively(File parent, List<File> children) {
+ for (File file : FileUtil.listMusicFiles(parent)) {
+ if (file.isFile()) {
+ children.add(file);
+ } else {
+ listFilesRecursively(file, children);
+ }
+ }
+ }
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/RESTMusicService.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/RESTMusicService.java
new file mode 100644
index 00000000..1d99f2d9
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/RESTMusicService.java
@@ -0,0 +1,768 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.service;
+
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.io.Reader;
+import java.net.URLEncoder;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.http.Header;
+import org.apache.http.HttpEntity;
+import org.apache.http.HttpHost;
+import org.apache.http.HttpResponse;
+import org.apache.http.NameValuePair;
+import org.apache.http.auth.AuthScope;
+import org.apache.http.auth.UsernamePasswordCredentials;
+import org.apache.http.client.entity.UrlEncodedFormEntity;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.client.methods.HttpUriRequest;
+import org.apache.http.conn.params.ConnManagerParams;
+import org.apache.http.conn.params.ConnPerRouteBean;
+import org.apache.http.conn.scheme.PlainSocketFactory;
+import org.apache.http.conn.scheme.Scheme;
+import org.apache.http.conn.scheme.SchemeRegistry;
+import org.apache.http.conn.scheme.SocketFactory;
+import org.apache.http.impl.client.DefaultHttpClient;
+import org.apache.http.impl.conn.tsccm.ThreadSafeClientConnManager;
+import org.apache.http.message.BasicHeader;
+import org.apache.http.message.BasicNameValuePair;
+import org.apache.http.params.BasicHttpParams;
+import org.apache.http.params.HttpConnectionParams;
+import org.apache.http.params.HttpParams;
+import org.apache.http.protocol.BasicHttpContext;
+import org.apache.http.protocol.ExecutionContext;
+import org.apache.http.protocol.HttpContext;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.content.pm.PackageInfo;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.net.ConnectivityManager;
+import android.net.NetworkInfo;
+import android.util.Log;
+import net.sourceforge.subsonic.androidapp.R;
+import net.sourceforge.subsonic.androidapp.domain.Indexes;
+import net.sourceforge.subsonic.androidapp.domain.JukeboxStatus;
+import net.sourceforge.subsonic.androidapp.domain.Lyrics;
+import net.sourceforge.subsonic.androidapp.domain.MusicDirectory;
+import net.sourceforge.subsonic.androidapp.domain.MusicFolder;
+import net.sourceforge.subsonic.androidapp.domain.Playlist;
+import net.sourceforge.subsonic.androidapp.domain.SearchCritera;
+import net.sourceforge.subsonic.androidapp.domain.SearchResult;
+import net.sourceforge.subsonic.androidapp.domain.ServerInfo;
+import net.sourceforge.subsonic.androidapp.domain.Version;
+import net.sourceforge.subsonic.androidapp.service.parser.AlbumListParser;
+import net.sourceforge.subsonic.androidapp.service.parser.ErrorParser;
+import net.sourceforge.subsonic.androidapp.service.parser.IndexesParser;
+import net.sourceforge.subsonic.androidapp.service.parser.JukeboxStatusParser;
+import net.sourceforge.subsonic.androidapp.service.parser.LicenseParser;
+import net.sourceforge.subsonic.androidapp.service.parser.LyricsParser;
+import net.sourceforge.subsonic.androidapp.service.parser.MusicDirectoryParser;
+import net.sourceforge.subsonic.androidapp.service.parser.MusicFoldersParser;
+import net.sourceforge.subsonic.androidapp.service.parser.PlaylistParser;
+import net.sourceforge.subsonic.androidapp.service.parser.PlaylistsParser;
+import net.sourceforge.subsonic.androidapp.service.parser.RandomSongsParser;
+import net.sourceforge.subsonic.androidapp.service.parser.SearchResult2Parser;
+import net.sourceforge.subsonic.androidapp.service.parser.SearchResultParser;
+import net.sourceforge.subsonic.androidapp.service.parser.VersionParser;
+import net.sourceforge.subsonic.androidapp.service.ssl.SSLSocketFactory;
+import net.sourceforge.subsonic.androidapp.service.ssl.TrustSelfSignedStrategy;
+import net.sourceforge.subsonic.androidapp.util.CancellableTask;
+import net.sourceforge.subsonic.androidapp.util.Constants;
+import net.sourceforge.subsonic.androidapp.util.FileUtil;
+import net.sourceforge.subsonic.androidapp.util.ProgressListener;
+import net.sourceforge.subsonic.androidapp.util.Util;
+
+/**
+ * @author Sindre Mehus
+ */
+public class RESTMusicService implements MusicService {
+
+ private static final String TAG = RESTMusicService.class.getSimpleName();
+
+ private static final int SOCKET_CONNECT_TIMEOUT = 10 * 1000;
+ private static final int SOCKET_READ_TIMEOUT_DEFAULT = 10 * 1000;
+ private static final int SOCKET_READ_TIMEOUT_DOWNLOAD = 30 * 1000;
+ private static final int SOCKET_READ_TIMEOUT_GET_RANDOM_SONGS = 60 * 1000;
+ private static final int SOCKET_READ_TIMEOUT_GET_PLAYLIST = 60 * 1000;
+
+ // Allow 20 seconds extra timeout per MB offset.
+ private static final double TIMEOUT_MILLIS_PER_OFFSET_BYTE = 20000.0 / 1000000.0;
+
+ /**
+ * URL from which to fetch latest versions.
+ */
+ private static final String VERSION_URL = "http://subsonic.org/backend/version.view";
+
+ private static final int HTTP_REQUEST_MAX_ATTEMPTS = 5;
+ private static final long REDIRECTION_CHECK_INTERVAL_MILLIS = 60L * 60L * 1000L;
+
+ private final DefaultHttpClient httpClient;
+ private long redirectionLastChecked;
+ private int redirectionNetworkType = -1;
+ private String redirectFrom;
+ private String redirectTo;
+ private final ThreadSafeClientConnManager connManager;
+
+ public RESTMusicService() {
+
+ // Create and initialize default HTTP parameters
+ HttpParams params = new BasicHttpParams();
+ ConnManagerParams.setMaxTotalConnections(params, 20);
+ ConnManagerParams.setMaxConnectionsPerRoute(params, new ConnPerRouteBean(20));
+ HttpConnectionParams.setConnectionTimeout(params, SOCKET_CONNECT_TIMEOUT);
+ HttpConnectionParams.setSoTimeout(params, SOCKET_READ_TIMEOUT_DEFAULT);
+
+ // Turn off stale checking. Our connections break all the time anyway,
+ // and it's not worth it to pay the penalty of checking every time.
+ HttpConnectionParams.setStaleCheckingEnabled(params, false);
+
+ // Create and initialize scheme registry
+ SchemeRegistry schemeRegistry = new SchemeRegistry();
+ schemeRegistry.register(new Scheme("http", PlainSocketFactory.getSocketFactory(), 80));
+ schemeRegistry.register(new Scheme("https", createSSLSocketFactory(), 443));
+
+ // Create an HttpClient with the ThreadSafeClientConnManager.
+ // This connection manager must be used if more than one thread will
+ // be using the HttpClient.
+ connManager = new ThreadSafeClientConnManager(params, schemeRegistry);
+ httpClient = new DefaultHttpClient(connManager, params);
+ }
+
+ private SocketFactory createSSLSocketFactory() {
+ try {
+ return new SSLSocketFactory(new TrustSelfSignedStrategy(), SSLSocketFactory.ALLOW_ALL_HOSTNAME_VERIFIER);
+ } catch (Throwable x) {
+ Log.e(TAG, "Failed to create custom SSL socket factory, using default.", x);
+ return org.apache.http.conn.ssl.SSLSocketFactory.getSocketFactory();
+ }
+ }
+
+ @Override
+ public void ping(Context context, ProgressListener progressListener) throws Exception {
+ Reader reader = getReader(context, progressListener, "ping", null);
+ try {
+ new ErrorParser(context).parse(reader);
+ } finally {
+ Util.close(reader);
+ }
+ }
+
+ @Override
+ public boolean isLicenseValid(Context context, ProgressListener progressListener) throws Exception {
+ Reader reader = getReader(context, progressListener, "getLicense", null);
+ try {
+ ServerInfo serverInfo = new LicenseParser(context).parse(reader);
+ return serverInfo.isLicenseValid();
+ } finally {
+ Util.close(reader);
+ }
+ }
+
+ public List<MusicFolder> getMusicFolders(boolean refresh, Context context, ProgressListener progressListener) throws Exception {
+ List<MusicFolder> cachedMusicFolders = readCachedMusicFolders(context);
+ if (cachedMusicFolders != null && !refresh) {
+ return cachedMusicFolders;
+ }
+
+ Reader reader = getReader(context, progressListener, "getMusicFolders", null);
+ try {
+ List<MusicFolder> musicFolders = new MusicFoldersParser(context).parse(reader, progressListener);
+ writeCachedMusicFolders(context, musicFolders);
+ return musicFolders;
+ } finally {
+ Util.close(reader);
+ }
+ }
+
+ @Override
+ public Indexes getIndexes(String musicFolderId, boolean refresh, Context context, ProgressListener progressListener) throws Exception {
+ Indexes cachedIndexes = readCachedIndexes(context, musicFolderId);
+ if (cachedIndexes != null && !refresh) {
+ return cachedIndexes;
+ }
+
+ long lastModified = cachedIndexes == null ? 0L : cachedIndexes.getLastModified();
+
+ List<String> parameterNames = new ArrayList<String>();
+ List<Object> parameterValues = new ArrayList<Object>();
+
+ parameterNames.add("ifModifiedSince");
+ parameterValues.add(lastModified);
+
+ if (musicFolderId != null) {
+ parameterNames.add("musicFolderId");
+ parameterValues.add(musicFolderId);
+ }
+
+ Reader reader = getReader(context, progressListener, "getIndexes", null, parameterNames, parameterValues);
+ try {
+ Indexes indexes = new IndexesParser(context).parse(reader, progressListener);
+ if (indexes != null) {
+ writeCachedIndexes(context, indexes, musicFolderId);
+ return indexes;
+ }
+ return cachedIndexes;
+ } finally {
+ Util.close(reader);
+ }
+ }
+
+ private Indexes readCachedIndexes(Context context, String musicFolderId) {
+ String filename = getCachedIndexesFilename(context, musicFolderId);
+ return FileUtil.deserialize(context, filename);
+ }
+
+ private void writeCachedIndexes(Context context, Indexes indexes, String musicFolderId) {
+ String filename = getCachedIndexesFilename(context, musicFolderId);
+ FileUtil.serialize(context, indexes, filename);
+ }
+
+ private String getCachedIndexesFilename(Context context, String musicFolderId) {
+ String s = Util.getRestUrl(context, null) + musicFolderId;
+ return "indexes-" + Math.abs(s.hashCode()) + ".ser";
+ }
+
+ private ArrayList<MusicFolder> readCachedMusicFolders(Context context) {
+ String filename = getCachedMusicFoldersFilename(context);
+ return FileUtil.deserialize(context, filename);
+ }
+
+ private void writeCachedMusicFolders(Context context, List<MusicFolder> musicFolders) {
+ String filename = getCachedMusicFoldersFilename(context);
+ FileUtil.serialize(context, new ArrayList<MusicFolder>(musicFolders), filename);
+ }
+
+ private String getCachedMusicFoldersFilename(Context context) {
+ String s = Util.getRestUrl(context, null);
+ return "musicFolders-" + Math.abs(s.hashCode()) + ".ser";
+ }
+
+ @Override
+ public MusicDirectory getMusicDirectory(String id, boolean refresh, Context context, ProgressListener progressListener) throws Exception {
+ Reader reader = getReader(context, progressListener, "getMusicDirectory", null, "id", id);
+ try {
+ return new MusicDirectoryParser(context).parse(reader, progressListener);
+ } finally {
+ Util.close(reader);
+ }
+ }
+
+ @Override
+ public SearchResult search(SearchCritera critera, Context context, ProgressListener progressListener) throws Exception {
+ try {
+ return searchNew(critera, context, progressListener);
+ } catch (ServerTooOldException x) {
+ // Ensure backward compatibility with REST 1.3.
+ return searchOld(critera, context, progressListener);
+ }
+ }
+
+ /**
+ * Search using the "search" REST method.
+ */
+ private SearchResult searchOld(SearchCritera critera, Context context, ProgressListener progressListener) throws Exception {
+ List<String> parameterNames = Arrays.asList("any", "songCount");
+ List<Object> parameterValues = Arrays.<Object>asList(critera.getQuery(), critera.getSongCount());
+ Reader reader = getReader(context, progressListener, "search", null, parameterNames, parameterValues);
+ try {
+ return new SearchResultParser(context).parse(reader, progressListener);
+ } finally {
+ Util.close(reader);
+ }
+ }
+
+ /**
+ * Search using the "search2" REST method, available in 1.4.0 and later.
+ */
+ private SearchResult searchNew(SearchCritera critera, Context context, ProgressListener progressListener) throws Exception {
+ checkServerVersion(context, "1.4", null);
+
+ List<String> parameterNames = Arrays.asList("query", "artistCount", "albumCount", "songCount");
+ List<Object> parameterValues = Arrays.<Object>asList(critera.getQuery(), critera.getArtistCount(),
+ critera.getAlbumCount(), critera.getSongCount());
+ Reader reader = getReader(context, progressListener, "search2", null, parameterNames, parameterValues);
+ try {
+ return new SearchResult2Parser(context).parse(reader, progressListener);
+ } finally {
+ Util.close(reader);
+ }
+ }
+
+ @Override
+ public MusicDirectory getPlaylist(String id, Context context, ProgressListener progressListener) throws Exception {
+ HttpParams params = new BasicHttpParams();
+ HttpConnectionParams.setSoTimeout(params, SOCKET_READ_TIMEOUT_GET_PLAYLIST);
+
+ Reader reader = getReader(context, progressListener, "getPlaylist", params, "id", id);
+ try {
+ return new PlaylistParser(context).parse(reader, progressListener);
+ } finally {
+ Util.close(reader);
+ }
+ }
+
+ @Override
+ public List<Playlist> getPlaylists(boolean refresh, Context context, ProgressListener progressListener) throws Exception {
+ Reader reader = getReader(context, progressListener, "getPlaylists", null);
+ try {
+ return new PlaylistsParser(context).parse(reader, progressListener);
+ } finally {
+ Util.close(reader);
+ }
+ }
+
+ @Override
+ public void createPlaylist(String id, String name, List<MusicDirectory.Entry> entries, Context context, ProgressListener progressListener) throws Exception {
+ List<String> parameterNames = new LinkedList<String>();
+ List<Object> parameterValues = new LinkedList<Object>();
+
+ if (id != null) {
+ parameterNames.add("playlistId");
+ parameterValues.add(id);
+ }
+ if (name != null) {
+ parameterNames.add("name");
+ parameterValues.add(name);
+ }
+ for (MusicDirectory.Entry entry : entries) {
+ parameterNames.add("songId");
+ parameterValues.add(entry.getId());
+ }
+
+ Reader reader = getReader(context, progressListener, "createPlaylist", null, parameterNames, parameterValues);
+ try {
+ new ErrorParser(context).parse(reader);
+ } finally {
+ Util.close(reader);
+ }
+ }
+
+ @Override
+ public Lyrics getLyrics(String artist, String title, Context context, ProgressListener progressListener) throws Exception {
+ Reader reader = getReader(context, progressListener, "getLyrics", null, Arrays.asList("artist", "title"), Arrays.<Object>asList(artist, title));
+ try {
+ return new LyricsParser(context).parse(reader, progressListener);
+ } finally {
+ Util.close(reader);
+ }
+ }
+
+ @Override
+ public void scrobble(String id, boolean submission, Context context, ProgressListener progressListener) throws Exception {
+ checkServerVersion(context, "1.5", "Scrobbling not supported.");
+ Reader reader = getReader(context, progressListener, "scrobble", null, Arrays.asList("id", "submission"), Arrays.<Object>asList(id, submission));
+ try {
+ new ErrorParser(context).parse(reader);
+ } finally {
+ Util.close(reader);
+ }
+ }
+
+ @Override
+ public MusicDirectory getAlbumList(String type, int size, int offset, Context context, ProgressListener progressListener) throws Exception {
+ Reader reader = getReader(context, progressListener, "getAlbumList",
+ null, Arrays.asList("type", "size", "offset"), Arrays.<Object>asList(type, size, offset));
+ try {
+ return new AlbumListParser(context).parse(reader, progressListener);
+ } finally {
+ Util.close(reader);
+ }
+ }
+
+ @Override
+ public MusicDirectory getRandomSongs(int size, Context context, ProgressListener progressListener) throws Exception {
+ HttpParams params = new BasicHttpParams();
+ HttpConnectionParams.setSoTimeout(params, SOCKET_READ_TIMEOUT_GET_RANDOM_SONGS);
+
+ Reader reader = getReader(context, progressListener, "getRandomSongs", params, "size", size);
+ try {
+ return new RandomSongsParser(context).parse(reader, progressListener);
+ } finally {
+ Util.close(reader);
+ }
+ }
+
+ @Override
+ public Version getLocalVersion(Context context) throws Exception {
+ PackageInfo packageInfo = context.getPackageManager().getPackageInfo("net.sourceforge.subsonic.androidapp", 0);
+ return new Version(packageInfo.versionName);
+ }
+
+ @Override
+ public Version getLatestVersion(Context context, ProgressListener progressListener) throws Exception {
+ Reader reader = getReaderForURL(context, VERSION_URL, null, null, null, progressListener);
+ try {
+ return new VersionParser().parse(reader);
+ } finally {
+ Util.close(reader);
+ }
+ }
+
+ private void checkServerVersion(Context context, String version, String text) throws ServerTooOldException {
+ Version serverVersion = Util.getServerRestVersion(context);
+ Version requiredVersion = new Version(version);
+ boolean ok = serverVersion == null || serverVersion.compareTo(requiredVersion) >= 0;
+
+ if (!ok) {
+ throw new ServerTooOldException(text, serverVersion, requiredVersion);
+ }
+ }
+
+ @Override
+ public Bitmap getCoverArt(Context context, MusicDirectory.Entry entry, int size, boolean saveToFile, ProgressListener progressListener) throws Exception {
+
+ // Synchronize on the entry so that we don't download concurrently for the same song.
+ synchronized (entry) {
+
+ // Use cached file, if existing.
+ Bitmap bitmap = FileUtil.getAlbumArtBitmap(context, entry, size);
+ if (bitmap != null) {
+ return bitmap;
+ }
+
+ String url = Util.getRestUrl(context, "getCoverArt");
+
+ InputStream in = null;
+ try {
+ List<String> parameterNames = Arrays.asList("id", "size");
+ List<Object> parameterValues = Arrays.<Object>asList(entry.getCoverArt(), size);
+ HttpEntity entity = getEntityForURL(context, url, null, parameterNames, parameterValues, progressListener);
+ in = entity.getContent();
+
+ // If content type is XML, an error occured. Get it.
+ String contentType = Util.getContentType(entity);
+ if (contentType != null && contentType.startsWith("text/xml")) {
+ new ErrorParser(context).parse(new InputStreamReader(in, Constants.UTF_8));
+ return null; // Never reached.
+ }
+
+ byte[] bytes = Util.toByteArray(in);
+
+ if (saveToFile) {
+ OutputStream out = null;
+ try {
+ out = new FileOutputStream(FileUtil.getAlbumArtFile(context, entry));
+ out.write(bytes);
+ } finally {
+ Util.close(out);
+ }
+ }
+
+ return BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
+
+ } finally {
+ Util.close(in);
+ }
+ }
+ }
+
+ @Override
+ public HttpResponse getDownloadInputStream(Context context, MusicDirectory.Entry song, long offset, int maxBitrate, CancellableTask task) throws Exception {
+
+ String url = Util.getRestUrl(context, "stream");
+
+ // Set socket read timeout. Note: The timeout increases as the offset gets larger. This is
+ // to avoid the thrashing effect seen when offset is combined with transcoding/downsampling on the server.
+ // In that case, the server uses a long time before sending any data, causing the client to time out.
+ HttpParams params = new BasicHttpParams();
+ int timeout = (int) (SOCKET_READ_TIMEOUT_DOWNLOAD + offset * TIMEOUT_MILLIS_PER_OFFSET_BYTE);
+ HttpConnectionParams.setSoTimeout(params, timeout);
+
+ // Add "Range" header if offset is given.
+ List<Header> headers = new ArrayList<Header>();
+ if (offset > 0) {
+ headers.add(new BasicHeader("Range", "bytes=" + offset + "-"));
+ }
+ List<String> parameterNames = Arrays.asList("id", "maxBitRate");
+ List<Object> parameterValues = Arrays.<Object>asList(song.getId(), maxBitrate);
+ HttpResponse response = getResponseForURL(context, url, params, parameterNames, parameterValues, headers, null, task);
+
+ // If content type is XML, an error occurred. Get it.
+ String contentType = Util.getContentType(response.getEntity());
+ if (contentType != null && contentType.startsWith("text/xml")) {
+ InputStream in = response.getEntity().getContent();
+ try {
+ new ErrorParser(context).parse(new InputStreamReader(in, Constants.UTF_8));
+ } finally {
+ Util.close(in);
+ }
+ }
+
+ return response;
+ }
+
+ @Override
+ public String getVideoUrl(Context context, String id) {
+ StringBuilder builder = new StringBuilder(Util.getRestUrl(context, "videoPlayer"));
+ builder.append("&id=").append(id);
+ builder.append("&maxBitRate=500");
+ builder.append("&autoplay=true");
+
+ String url = rewriteUrlWithRedirect(context, builder.toString());
+ Log.i(TAG, "Using video URL: " + url);
+ return url;
+ }
+
+ @Override
+ public JukeboxStatus updateJukeboxPlaylist(List<String> ids, Context context, ProgressListener progressListener) throws Exception {
+ int n = ids.size();
+ List<String> parameterNames = new ArrayList<String>(n + 1);
+ parameterNames.add("action");
+ for (int i = 0; i < n; i++) {
+ parameterNames.add("id");
+ }
+ List<Object> parameterValues = new ArrayList<Object>();
+ parameterValues.add("set");
+ parameterValues.addAll(ids);
+
+ return executeJukeboxCommand(context, progressListener, parameterNames, parameterValues);
+ }
+
+ @Override
+ public JukeboxStatus skipJukebox(int index, int offsetSeconds, Context context, ProgressListener progressListener) throws Exception {
+ List<String> parameterNames = Arrays.asList("action", "index", "offset");
+ List<Object> parameterValues = Arrays.<Object>asList("skip", index, offsetSeconds);
+ return executeJukeboxCommand(context, progressListener, parameterNames, parameterValues);
+ }
+
+ @Override
+ public JukeboxStatus stopJukebox(Context context, ProgressListener progressListener) throws Exception {
+ return executeJukeboxCommand(context, progressListener, Arrays.asList("action"), Arrays.<Object>asList("stop"));
+ }
+
+ @Override
+ public JukeboxStatus startJukebox(Context context, ProgressListener progressListener) throws Exception {
+ return executeJukeboxCommand(context, progressListener, Arrays.asList("action"), Arrays.<Object>asList("start"));
+ }
+
+ @Override
+ public JukeboxStatus getJukeboxStatus(Context context, ProgressListener progressListener) throws Exception {
+ return executeJukeboxCommand(context, progressListener, Arrays.asList("action"), Arrays.<Object>asList("status"));
+ }
+
+ @Override
+ public JukeboxStatus setJukeboxGain(float gain, Context context, ProgressListener progressListener) throws Exception {
+ List<String> parameterNames = Arrays.asList("action", "gain");
+ List<Object> parameterValues = Arrays.<Object>asList("setGain", gain);
+ return executeJukeboxCommand(context, progressListener, parameterNames, parameterValues);
+
+ }
+
+ private JukeboxStatus executeJukeboxCommand(Context context, ProgressListener progressListener, List<String> parameterNames, List<Object> parameterValues) throws Exception {
+ checkServerVersion(context, "1.7", "Jukebox not supported.");
+ Reader reader = getReader(context, progressListener, "jukeboxControl", null, parameterNames, parameterValues);
+ try {
+ return new JukeboxStatusParser(context).parse(reader);
+ } finally {
+ Util.close(reader);
+ }
+ }
+
+ private Reader getReader(Context context, ProgressListener progressListener, String method, HttpParams requestParams) throws Exception {
+ return getReader(context, progressListener, method, requestParams, Collections.<String>emptyList(), Collections.emptyList());
+ }
+
+ private Reader getReader(Context context, ProgressListener progressListener, String method,
+ HttpParams requestParams, String parameterName, Object parameterValue) throws Exception {
+ return getReader(context, progressListener, method, requestParams, Arrays.asList(parameterName), Arrays.<Object>asList(parameterValue));
+ }
+
+ private Reader getReader(Context context, ProgressListener progressListener, String method,
+ HttpParams requestParams, List<String> parameterNames, List<Object> parameterValues) throws Exception {
+
+ if (progressListener != null) {
+ progressListener.updateProgress(R.string.service_connecting);
+ }
+
+ String url = Util.getRestUrl(context, method);
+ return getReaderForURL(context, url, requestParams, parameterNames, parameterValues, progressListener);
+ }
+
+ private Reader getReaderForURL(Context context, String url, HttpParams requestParams, List<String> parameterNames,
+ List<Object> parameterValues, ProgressListener progressListener) throws Exception {
+ HttpEntity entity = getEntityForURL(context, url, requestParams, parameterNames, parameterValues, progressListener);
+ if (entity == null) {
+ throw new RuntimeException("No entity received for URL " + url);
+ }
+
+ InputStream in = entity.getContent();
+ return new InputStreamReader(in, Constants.UTF_8);
+ }
+
+ private HttpEntity getEntityForURL(Context context, String url, HttpParams requestParams, List<String> parameterNames,
+ List<Object> parameterValues, ProgressListener progressListener) throws Exception {
+ return getResponseForURL(context, url, requestParams, parameterNames, parameterValues, null, progressListener, null).getEntity();
+ }
+
+ private HttpResponse getResponseForURL(Context context, String url, HttpParams requestParams,
+ List<String> parameterNames, List<Object> parameterValues,
+ List<Header> headers, ProgressListener progressListener, CancellableTask task) throws Exception {
+ Log.d(TAG, "Connections in pool: " + connManager.getConnectionsInPool());
+
+ // If not too many parameters, extract them to the URL rather than relying on the HTTP POST request being
+ // received intact. Remember, HTTP POST requests are converted to GET requests during HTTP redirects, thus
+ // loosing its entity.
+ if (parameterNames != null && parameterNames.size() < 10) {
+ StringBuilder builder = new StringBuilder(url);
+ for (int i = 0; i < parameterNames.size(); i++) {
+ builder.append("&").append(parameterNames.get(i)).append("=");
+ builder.append(URLEncoder.encode(String.valueOf(parameterValues.get(i)), "UTF-8"));
+ }
+ url = builder.toString();
+ parameterNames = null;
+ parameterValues = null;
+ }
+
+ String rewrittenUrl = rewriteUrlWithRedirect(context, url);
+ return executeWithRetry(context, rewrittenUrl, url, requestParams, parameterNames, parameterValues, headers, progressListener, task);
+ }
+
+ private HttpResponse executeWithRetry(Context context, String url, String originalUrl, HttpParams requestParams,
+ List<String> parameterNames, List<Object> parameterValues,
+ List<Header> headers, ProgressListener progressListener, CancellableTask task) throws IOException {
+ Log.i(TAG, "Using URL " + url);
+
+ final AtomicReference<Boolean> cancelled = new AtomicReference<Boolean>(false);
+ int attempts = 0;
+ while (true) {
+ attempts++;
+ HttpContext httpContext = new BasicHttpContext();
+ final HttpPost request = new HttpPost(url);
+
+ if (task != null) {
+ // Attempt to abort the HTTP request if the task is cancelled.
+ task.setOnCancelListener(new CancellableTask.OnCancelListener() {
+ @Override
+ public void onCancel() {
+ cancelled.set(true);
+ request.abort();
+ }
+ });
+ }
+
+ if (parameterNames != null) {
+ List<NameValuePair> params = new ArrayList<NameValuePair>();
+ for (int i = 0; i < parameterNames.size(); i++) {
+ params.add(new BasicNameValuePair(parameterNames.get(i), String.valueOf(parameterValues.get(i))));
+ }
+ request.setEntity(new UrlEncodedFormEntity(params, Constants.UTF_8));
+ }
+
+ if (requestParams != null) {
+ request.setParams(requestParams);
+ Log.d(TAG, "Socket read timeout: " + HttpConnectionParams.getSoTimeout(requestParams) + " ms.");
+ }
+
+ if (headers != null) {
+ for (Header header : headers) {
+ request.addHeader(header);
+ }
+ }
+
+ // Set credentials to get through apache proxies that require authentication.
+ SharedPreferences prefs = Util.getPreferences(context);
+ int instance = prefs.getInt(Constants.PREFERENCES_KEY_SERVER_INSTANCE, 1);
+ String username = prefs.getString(Constants.PREFERENCES_KEY_USERNAME + instance, null);
+ String password = prefs.getString(Constants.PREFERENCES_KEY_PASSWORD + instance, null);
+ httpClient.getCredentialsProvider().setCredentials(new AuthScope(AuthScope.ANY_HOST, AuthScope.ANY_PORT),
+ new UsernamePasswordCredentials(username, password));
+
+ try {
+ HttpResponse response = httpClient.execute(request, httpContext);
+ detectRedirect(originalUrl, context, httpContext);
+ return response;
+ } catch (IOException x) {
+ request.abort();
+ if (attempts >= HTTP_REQUEST_MAX_ATTEMPTS || cancelled.get()) {
+ throw x;
+ }
+ if (progressListener != null) {
+ String msg = context.getResources().getString(R.string.music_service_retry, attempts, HTTP_REQUEST_MAX_ATTEMPTS - 1);
+ progressListener.updateProgress(msg);
+ }
+ Log.w(TAG, "Got IOException (" + attempts + "), will retry", x);
+ increaseTimeouts(requestParams);
+ Util.sleepQuietly(2000L);
+ }
+ }
+ }
+
+ private void increaseTimeouts(HttpParams requestParams) {
+ if (requestParams != null) {
+ int connectTimeout = HttpConnectionParams.getConnectionTimeout(requestParams);
+ if (connectTimeout != 0) {
+ HttpConnectionParams.setConnectionTimeout(requestParams, (int) (connectTimeout * 1.3F));
+ }
+ int readTimeout = HttpConnectionParams.getSoTimeout(requestParams);
+ if (readTimeout != 0) {
+ HttpConnectionParams.setSoTimeout(requestParams, (int) (readTimeout * 1.5F));
+ }
+ }
+ }
+
+ private void detectRedirect(String originalUrl, Context context, HttpContext httpContext) {
+ HttpUriRequest request = (HttpUriRequest) httpContext.getAttribute(ExecutionContext.HTTP_REQUEST);
+ HttpHost host = (HttpHost) httpContext.getAttribute(ExecutionContext.HTTP_TARGET_HOST);
+ String redirectedUrl = host.toURI() + request.getURI();
+
+ redirectFrom = originalUrl.substring(0, originalUrl.indexOf("/rest/"));
+ redirectTo = redirectedUrl.substring(0, redirectedUrl.indexOf("/rest/"));
+
+ Log.i(TAG, redirectFrom + " redirects to " + redirectTo);
+ redirectionLastChecked = System.currentTimeMillis();
+ redirectionNetworkType = getCurrentNetworkType(context);
+ }
+
+ private String rewriteUrlWithRedirect(Context context, String url) {
+
+ // Only cache for a certain time.
+ if (System.currentTimeMillis() - redirectionLastChecked > REDIRECTION_CHECK_INTERVAL_MILLIS) {
+ return url;
+ }
+
+ // Ignore cache if network type has changed.
+ if (redirectionNetworkType != getCurrentNetworkType(context)) {
+ return url;
+ }
+
+ if (redirectFrom == null || redirectTo == null) {
+ return url;
+ }
+
+ return url.replace(redirectFrom, redirectTo);
+ }
+
+ private int getCurrentNetworkType(Context context) {
+ ConnectivityManager manager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
+ NetworkInfo networkInfo = manager.getActiveNetworkInfo();
+ return networkInfo == null ? -1 : networkInfo.getType();
+ }
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/Scrobbler.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/Scrobbler.java
new file mode 100644
index 00000000..ce121a4b
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/Scrobbler.java
@@ -0,0 +1,52 @@
+package net.sourceforge.subsonic.androidapp.service;
+
+import android.content.Context;
+import android.util.Log;
+import net.sourceforge.subsonic.androidapp.util.Util;
+
+/**
+ * Scrobbles played songs to Last.fm.
+ *
+ * @author Sindre Mehus
+ * @version $Id$
+ */
+public class Scrobbler {
+
+ private static final String TAG = Scrobbler.class.getSimpleName();
+
+ private String lastSubmission;
+ private String lastNowPlaying;
+
+ public void scrobble(final Context context, final DownloadFile song, final boolean submission) {
+ if (song == null || !Util.isScrobblingEnabled(context)) {
+ return;
+ }
+ final String id = song.getSong().getId();
+
+ // Avoid duplicate registrations.
+ if (submission && id.equals(lastSubmission)) {
+ return;
+ }
+ if (!submission && id.equals(lastNowPlaying)) {
+ return;
+ }
+ if (submission) {
+ lastSubmission = id;
+ } else {
+ lastNowPlaying = id;
+ }
+
+ new Thread("Scrobble " + song) {
+ @Override
+ public void run() {
+ MusicService service = MusicServiceFactory.getMusicService(context);
+ try {
+ service.scrobble(id, submission, context, null);
+ Log.i(TAG, "Scrobbled '" + (submission ? "submission" : "now playing") + "' for " + song);
+ } catch (Exception x) {
+ Log.i(TAG, "Failed to scrobble'" + (submission ? "submission" : "now playing") + "' for " + song, x);
+ }
+ }
+ }.start();
+ }
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/ServerTooOldException.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/ServerTooOldException.java
new file mode 100644
index 00000000..9d433385
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/ServerTooOldException.java
@@ -0,0 +1,51 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.service;
+
+import net.sourceforge.subsonic.androidapp.domain.Version;
+
+/**
+ * Thrown if the REST API version implemented by the server is too old.
+ *
+ * @author Sindre Mehus
+ * @version $Id$
+ */
+public class ServerTooOldException extends Exception {
+
+ private final String text;
+ private final Version serverVersion;
+ private final Version requiredVersion;
+
+ public ServerTooOldException(String text, Version serverVersion, Version requiredVersion) {
+ this.text = text;
+ this.serverVersion = serverVersion;
+ this.requiredVersion = requiredVersion;
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder builder = new StringBuilder();
+ if (text != null) {
+ builder.append(text).append(" ");
+ }
+ builder.append("Server API version too old. ");
+ builder.append("Requires ").append(requiredVersion).append(" but is ").append(serverVersion).append(".");
+ return builder.toString();
+ }
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/AbstractParser.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/AbstractParser.java
new file mode 100644
index 00000000..4ddff7e9
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/AbstractParser.java
@@ -0,0 +1,138 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.service.parser;
+
+import java.io.Reader;
+
+import org.xmlpull.v1.XmlPullParser;
+
+import android.content.Context;
+import android.util.Xml;
+import net.sourceforge.subsonic.androidapp.R;
+import net.sourceforge.subsonic.androidapp.domain.Version;
+import net.sourceforge.subsonic.androidapp.util.ProgressListener;
+import net.sourceforge.subsonic.androidapp.util.Util;
+
+/**
+ * @author Sindre Mehus
+ */
+public abstract class AbstractParser {
+
+ private final Context context;
+ private XmlPullParser parser;
+ private boolean rootElementFound;
+
+ public AbstractParser(Context context) {
+ this.context = context;
+ }
+
+ protected Context getContext() {
+ return context;
+ }
+
+ protected void handleError() throws Exception {
+ int code = getInteger("code");
+ String message;
+ switch (code) {
+ case 20:
+ message = context.getResources().getString(R.string.parser_upgrade_client);
+ break;
+ case 30:
+ message = context.getResources().getString(R.string.parser_upgrade_server);
+ break;
+ case 40:
+ message = context.getResources().getString(R.string.parser_not_authenticated);
+ break;
+ case 50:
+ message = context.getResources().getString(R.string.parser_not_authorized);
+ break;
+ default:
+ message = get("message");
+ break;
+ }
+ throw new SubsonicRESTException(code, message);
+ }
+
+ protected void updateProgress(ProgressListener progressListener, int messageId) {
+ if (progressListener != null) {
+ progressListener.updateProgress(messageId);
+ }
+ }
+
+ protected void updateProgress(ProgressListener progressListener, String message) {
+ if (progressListener != null) {
+ progressListener.updateProgress(message);
+ }
+ }
+
+ protected String getText() {
+ return parser.getText();
+ }
+
+ protected String get(String name) {
+ return parser.getAttributeValue(null, name);
+ }
+
+ protected boolean getBoolean(String name) {
+ return "true".equals(get(name));
+ }
+
+ protected Integer getInteger(String name) {
+ String s = get(name);
+ return s == null ? null : Integer.valueOf(s);
+ }
+
+ protected Long getLong(String name) {
+ String s = get(name);
+ return s == null ? null : Long.valueOf(s);
+ }
+
+ protected Float getFloat(String name) {
+ String s = get(name);
+ return s == null ? null : Float.valueOf(s);
+ }
+
+ protected void init(Reader reader) throws Exception {
+ parser = Xml.newPullParser();
+ parser.setInput(reader);
+ rootElementFound = false;
+ }
+
+ protected int nextParseEvent() throws Exception {
+ return parser.next();
+ }
+
+ protected String getElementName() {
+ String name = parser.getName();
+ if ("subsonic-response".equals(name)) {
+ rootElementFound = true;
+ String version = get("version");
+ if (version != null) {
+ Util.setServerRestVersion(context, new Version(version));
+ }
+ }
+ return name;
+ }
+
+ protected void validate() throws Exception {
+ if (!rootElementFound) {
+ throw new Exception(context.getResources().getString(R.string.background_task_parse_error));
+ }
+ }
+} \ No newline at end of file
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/AlbumListParser.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/AlbumListParser.java
new file mode 100644
index 00000000..298ef114
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/AlbumListParser.java
@@ -0,0 +1,62 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.service.parser;
+
+import android.content.Context;
+import net.sourceforge.subsonic.androidapp.R;
+import net.sourceforge.subsonic.androidapp.domain.MusicDirectory;
+import net.sourceforge.subsonic.androidapp.util.ProgressListener;
+import org.xmlpull.v1.XmlPullParser;
+
+import java.io.Reader;
+
+/**
+ * @author Sindre Mehus
+ */
+public class AlbumListParser extends MusicDirectoryEntryParser {
+
+ public AlbumListParser(Context context) {
+ super(context);
+ }
+
+ public MusicDirectory parse(Reader reader, ProgressListener progressListener) throws Exception {
+
+ updateProgress(progressListener, R.string.parser_reading);
+ init(reader);
+
+ MusicDirectory dir = new MusicDirectory();
+ int eventType;
+ do {
+ eventType = nextParseEvent();
+ if (eventType == XmlPullParser.START_TAG) {
+ String name = getElementName();
+ if ("album".equals(name)) {
+ dir.addChild(parseEntry());
+ } else if ("error".equals(name)) {
+ handleError();
+ }
+ }
+ } while (eventType != XmlPullParser.END_DOCUMENT);
+
+ validate();
+ updateProgress(progressListener, R.string.parser_reading_done);
+
+ return dir;
+ }
+} \ No newline at end of file
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/ErrorParser.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/ErrorParser.java
new file mode 100644
index 00000000..b2c61c5b
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/ErrorParser.java
@@ -0,0 +1,49 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.service.parser;
+
+import android.content.Context;
+import org.xmlpull.v1.XmlPullParser;
+
+import java.io.Reader;
+
+/**
+ * @author Sindre Mehus
+ */
+public class ErrorParser extends AbstractParser {
+
+ public ErrorParser(Context context) {
+ super(context);
+ }
+
+ public void parse(Reader reader) throws Exception {
+
+ init(reader);
+
+ int eventType;
+ do {
+ eventType = nextParseEvent();
+ if (eventType == XmlPullParser.START_TAG && "error".equals(getElementName())) {
+ handleError();
+ }
+ } while (eventType != XmlPullParser.END_DOCUMENT);
+
+ validate();
+ }
+} \ No newline at end of file
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/IndexesParser.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/IndexesParser.java
new file mode 100644
index 00000000..83ef3e77
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/IndexesParser.java
@@ -0,0 +1,104 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.service.parser;
+
+import java.io.Reader;
+import java.util.List;
+import java.util.ArrayList;
+
+import org.xmlpull.v1.XmlPullParser;
+
+import android.content.Context;
+import net.sourceforge.subsonic.androidapp.R;
+import net.sourceforge.subsonic.androidapp.domain.Artist;
+import net.sourceforge.subsonic.androidapp.domain.Indexes;
+import net.sourceforge.subsonic.androidapp.util.ProgressListener;
+import android.util.Log;
+
+/**
+ * @author Sindre Mehus
+ */
+public class IndexesParser extends AbstractParser {
+ private static final String TAG = IndexesParser.class.getSimpleName();
+
+ public IndexesParser(Context context) {
+ super(context);
+ }
+
+ public Indexes parse(Reader reader, ProgressListener progressListener) throws Exception {
+
+ long t0 = System.currentTimeMillis();
+ updateProgress(progressListener, R.string.parser_reading);
+ init(reader);
+
+ List<Artist> artists = new ArrayList<Artist>();
+ List<Artist> shortcuts = new ArrayList<Artist>();
+ Long lastModified = null;
+ int eventType;
+ String index = "#";
+ boolean changed = false;
+
+ do {
+ eventType = nextParseEvent();
+ if (eventType == XmlPullParser.START_TAG) {
+ String name = getElementName();
+ if ("indexes".equals(name)) {
+ changed = true;
+ lastModified = getLong("lastModified");
+ } else if ("index".equals(name)) {
+ index = get("name");
+
+ } else if ("artist".equals(name)) {
+ Artist artist = new Artist();
+ artist.setId(get("id"));
+ artist.setName(get("name"));
+ artist.setIndex(index);
+ artists.add(artist);
+
+ if (artists.size() % 10 == 0) {
+ String msg = getContext().getResources().getString(R.string.parser_artist_count, artists.size());
+ updateProgress(progressListener, msg);
+ }
+ } else if ("shortcut".equals(name)) {
+ Artist shortcut = new Artist();
+ shortcut.setId(get("id"));
+ shortcut.setName(get("name"));
+ shortcut.setIndex("*");
+ shortcuts.add(shortcut);
+ } else if ("error".equals(name)) {
+ handleError();
+ }
+ }
+ } while (eventType != XmlPullParser.END_DOCUMENT);
+
+ validate();
+
+ if (!changed) {
+ return null;
+ }
+
+ long t1 = System.currentTimeMillis();
+ Log.d(TAG, "Got " + artists.size() + " artist(s) in " + (t1 - t0) + "ms.");
+
+ String msg = getContext().getResources().getString(R.string.parser_artist_count, artists.size());
+ updateProgress(progressListener, msg);
+
+ return new Indexes(lastModified == null ? 0L : lastModified, shortcuts, artists);
+ }
+} \ No newline at end of file
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/JukeboxStatusParser.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/JukeboxStatusParser.java
new file mode 100644
index 00000000..2a61508d
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/JukeboxStatusParser.java
@@ -0,0 +1,62 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.service.parser;
+
+import java.io.Reader;
+
+import org.xmlpull.v1.XmlPullParser;
+
+import android.content.Context;
+import net.sourceforge.subsonic.androidapp.domain.JukeboxStatus;
+
+/**
+ * @author Sindre Mehus
+ */
+public class JukeboxStatusParser extends AbstractParser {
+
+ public JukeboxStatusParser(Context context) {
+ super(context);
+ }
+
+ public JukeboxStatus parse(Reader reader) throws Exception {
+
+ init(reader);
+
+ JukeboxStatus jukeboxStatus = new JukeboxStatus();
+ int eventType;
+ do {
+ eventType = nextParseEvent();
+ if (eventType == XmlPullParser.START_TAG) {
+ String name = getElementName();
+ if ("jukeboxPlaylist".equals(name) || "jukeboxStatus".equals(name)) {
+ jukeboxStatus.setPositionSeconds(getInteger("position"));
+ jukeboxStatus.setCurrentIndex(getInteger("currentIndex"));
+ jukeboxStatus.setPlaying(getBoolean("playing"));
+ jukeboxStatus.setGain(getFloat("gain"));
+ } else if ("error".equals(name)) {
+ handleError();
+ }
+ }
+ } while (eventType != XmlPullParser.END_DOCUMENT);
+
+ validate();
+
+ return jukeboxStatus;
+ }
+} \ No newline at end of file
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/LicenseParser.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/LicenseParser.java
new file mode 100644
index 00000000..636c3e6e
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/LicenseParser.java
@@ -0,0 +1,62 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.service.parser;
+
+import android.content.Context;
+import org.xmlpull.v1.XmlPullParser;
+
+import java.io.Reader;
+
+import net.sourceforge.subsonic.androidapp.domain.ServerInfo;
+import net.sourceforge.subsonic.androidapp.domain.Version;
+
+/**
+ * @author Sindre Mehus
+ */
+public class LicenseParser extends AbstractParser {
+
+ public LicenseParser(Context context) {
+ super(context);
+ }
+
+ public ServerInfo parse(Reader reader) throws Exception {
+
+ init(reader);
+
+ ServerInfo serverInfo = new ServerInfo();
+ int eventType;
+ do {
+ eventType = nextParseEvent();
+ if (eventType == XmlPullParser.START_TAG) {
+ String name = getElementName();
+ if ("subsonic-response".equals(name)) {
+ serverInfo.setRestVersion(new Version(get("version")));
+ } else if ("license".equals(name)) {
+ serverInfo.setLicenseValid(getBoolean("valid"));
+ } else if ("error".equals(name)) {
+ handleError();
+ }
+ }
+ } while (eventType != XmlPullParser.END_DOCUMENT);
+
+ validate();
+
+ return serverInfo;
+ }
+} \ No newline at end of file
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/LyricsParser.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/LyricsParser.java
new file mode 100644
index 00000000..698fb4b8
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/LyricsParser.java
@@ -0,0 +1,65 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2010 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.service.parser;
+
+import android.content.Context;
+import net.sourceforge.subsonic.androidapp.R;
+import net.sourceforge.subsonic.androidapp.domain.Lyrics;
+import net.sourceforge.subsonic.androidapp.util.ProgressListener;
+import org.xmlpull.v1.XmlPullParser;
+
+import java.io.Reader;
+
+/**
+ * @author Sindre Mehus
+ */
+public class LyricsParser extends AbstractParser {
+
+ public LyricsParser(Context context) {
+ super(context);
+ }
+
+ public Lyrics parse(Reader reader, ProgressListener progressListener) throws Exception {
+ updateProgress(progressListener, R.string.parser_reading);
+ init(reader);
+
+ Lyrics lyrics = null;
+ int eventType;
+ do {
+ eventType = nextParseEvent();
+ if (eventType == XmlPullParser.START_TAG) {
+ String name = getElementName();
+ if ("lyrics".equals(name)) {
+ lyrics = new Lyrics();
+ lyrics.setArtist(get("artist"));
+ lyrics.setTitle(get("title"));
+ } else if ("error".equals(name)) {
+ handleError();
+ }
+ } else if (eventType == XmlPullParser.TEXT) {
+ if (lyrics != null && lyrics.getText() == null) {
+ lyrics.setText(getText());
+ }
+ }
+ } while (eventType != XmlPullParser.END_DOCUMENT);
+
+ validate();
+ return lyrics;
+ }
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/MusicDirectoryEntryParser.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/MusicDirectoryEntryParser.java
new file mode 100644
index 00000000..3da90613
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/MusicDirectoryEntryParser.java
@@ -0,0 +1,59 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.service.parser;
+
+import android.content.Context;
+import net.sourceforge.subsonic.androidapp.domain.MusicDirectory;
+
+/**
+ * @author Sindre Mehus
+ */
+public class MusicDirectoryEntryParser extends AbstractParser {
+
+ public MusicDirectoryEntryParser(Context context) {
+ super(context);
+ }
+
+ protected MusicDirectory.Entry parseEntry() {
+ MusicDirectory.Entry entry = new MusicDirectory.Entry();
+ entry.setId(get("id"));
+ entry.setParent(get("parent"));
+ entry.setTitle(get("title"));
+ entry.setDirectory(getBoolean("isDir"));
+ entry.setCoverArt(get("coverArt"));
+ entry.setArtist(get("artist"));
+
+ if (!entry.isDirectory()) {
+ entry.setAlbum(get("album"));
+ entry.setTrack(getInteger("track"));
+ entry.setYear(getInteger("year"));
+ entry.setGenre(get("genre"));
+ entry.setContentType(get("contentType"));
+ entry.setSuffix(get("suffix"));
+ entry.setTranscodedContentType(get("transcodedContentType"));
+ entry.setTranscodedSuffix(get("transcodedSuffix"));
+ entry.setSize(getLong("size"));
+ entry.setDuration(getInteger("duration"));
+ entry.setBitRate(getInteger("bitRate"));
+ entry.setPath(get("path"));
+ entry.setVideo(getBoolean("isVideo"));
+ }
+ return entry;
+ }
+} \ No newline at end of file
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/MusicDirectoryParser.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/MusicDirectoryParser.java
new file mode 100644
index 00000000..b818fc3d
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/MusicDirectoryParser.java
@@ -0,0 +1,71 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.service.parser;
+
+import android.content.Context;
+import android.util.Log;
+import net.sourceforge.subsonic.androidapp.R;
+import net.sourceforge.subsonic.androidapp.domain.MusicDirectory;
+import net.sourceforge.subsonic.androidapp.util.ProgressListener;
+import org.xmlpull.v1.XmlPullParser;
+
+import java.io.Reader;
+
+/**
+ * @author Sindre Mehus
+ */
+public class MusicDirectoryParser extends MusicDirectoryEntryParser {
+
+ private static final String TAG = MusicDirectoryParser.class.getSimpleName();
+
+ public MusicDirectoryParser(Context context) {
+ super(context);
+ }
+
+ public MusicDirectory parse(Reader reader, ProgressListener progressListener) throws Exception {
+
+ long t0 = System.currentTimeMillis();
+ updateProgress(progressListener, R.string.parser_reading);
+ init(reader);
+
+ MusicDirectory dir = new MusicDirectory();
+ int eventType;
+ do {
+ eventType = nextParseEvent();
+ if (eventType == XmlPullParser.START_TAG) {
+ String name = getElementName();
+ if ("child".equals(name)) {
+ dir.addChild(parseEntry());
+ } else if ("directory".equals(name)) {
+ dir.setName(get("name"));
+ } else if ("error".equals(name)) {
+ handleError();
+ }
+ }
+ } while (eventType != XmlPullParser.END_DOCUMENT);
+
+ validate();
+ updateProgress(progressListener, R.string.parser_reading_done);
+
+ long t1 = System.currentTimeMillis();
+ Log.d(TAG, "Got music directory in " + (t1 - t0) + "ms.");
+
+ return dir;
+ }
+} \ No newline at end of file
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/MusicFoldersParser.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/MusicFoldersParser.java
new file mode 100644
index 00000000..35057bd9
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/MusicFoldersParser.java
@@ -0,0 +1,69 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.service.parser;
+
+import java.io.Reader;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.xmlpull.v1.XmlPullParser;
+
+import android.content.Context;
+import net.sourceforge.subsonic.androidapp.R;
+import net.sourceforge.subsonic.androidapp.domain.MusicFolder;
+import net.sourceforge.subsonic.androidapp.domain.Playlist;
+import net.sourceforge.subsonic.androidapp.util.ProgressListener;
+
+/**
+ * @author Sindre Mehus
+ */
+public class MusicFoldersParser extends AbstractParser {
+
+ public MusicFoldersParser(Context context) {
+ super(context);
+ }
+
+ public List<MusicFolder> parse(Reader reader, ProgressListener progressListener) throws Exception {
+
+ updateProgress(progressListener, R.string.parser_reading);
+ init(reader);
+
+ List<MusicFolder> result = new ArrayList<MusicFolder>();
+ int eventType;
+ do {
+ eventType = nextParseEvent();
+ if (eventType == XmlPullParser.START_TAG) {
+ String tag = getElementName();
+ if ("musicFolder".equals(tag)) {
+ String id = get("id");
+ String name = get("name");
+ result.add(new MusicFolder(id, name));
+ } else if ("error".equals(tag)) {
+ handleError();
+ }
+ }
+ } while (eventType != XmlPullParser.END_DOCUMENT);
+
+ validate();
+ updateProgress(progressListener, R.string.parser_reading_done);
+
+ return result;
+ }
+
+} \ No newline at end of file
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/PlaylistParser.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/PlaylistParser.java
new file mode 100644
index 00000000..ee829639
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/PlaylistParser.java
@@ -0,0 +1,62 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.service.parser;
+
+import android.content.Context;
+import net.sourceforge.subsonic.androidapp.R;
+import net.sourceforge.subsonic.androidapp.domain.MusicDirectory;
+import net.sourceforge.subsonic.androidapp.util.ProgressListener;
+import org.xmlpull.v1.XmlPullParser;
+
+import java.io.Reader;
+
+/**
+ * @author Sindre Mehus
+ */
+public class PlaylistParser extends MusicDirectoryEntryParser {
+
+ public PlaylistParser(Context context) {
+ super(context);
+ }
+
+ public MusicDirectory parse(Reader reader, ProgressListener progressListener) throws Exception {
+ updateProgress(progressListener, R.string.parser_reading);
+ init(reader);
+
+ MusicDirectory dir = new MusicDirectory();
+ int eventType;
+ do {
+ eventType = nextParseEvent();
+ if (eventType == XmlPullParser.START_TAG) {
+ String name = getElementName();
+ if ("entry".equals(name)) {
+ dir.addChild(parseEntry());
+ } else if ("error".equals(name)) {
+ handleError();
+ }
+ }
+ } while (eventType != XmlPullParser.END_DOCUMENT);
+
+ validate();
+ updateProgress(progressListener, R.string.parser_reading_done);
+
+ return dir;
+ }
+
+} \ No newline at end of file
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/PlaylistsParser.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/PlaylistsParser.java
new file mode 100644
index 00000000..c1b88b8c
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/PlaylistsParser.java
@@ -0,0 +1,67 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.service.parser;
+
+import android.content.Context;
+import net.sourceforge.subsonic.androidapp.R;
+import net.sourceforge.subsonic.androidapp.domain.Playlist;
+import net.sourceforge.subsonic.androidapp.util.ProgressListener;
+import org.xmlpull.v1.XmlPullParser;
+
+import java.io.Reader;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * @author Sindre Mehus
+ */
+public class PlaylistsParser extends AbstractParser {
+
+ public PlaylistsParser(Context context) {
+ super(context);
+ }
+
+ public List<Playlist> parse(Reader reader, ProgressListener progressListener) throws Exception {
+
+ updateProgress(progressListener, R.string.parser_reading);
+ init(reader);
+
+ List<Playlist> result = new ArrayList<Playlist>();
+ int eventType;
+ do {
+ eventType = nextParseEvent();
+ if (eventType == XmlPullParser.START_TAG) {
+ String tag = getElementName();
+ if ("playlist".equals(tag)) {
+ String id = get("id");
+ String name = get("name");
+ result.add(new Playlist(id, name));
+ } else if ("error".equals(tag)) {
+ handleError();
+ }
+ }
+ } while (eventType != XmlPullParser.END_DOCUMENT);
+
+ validate();
+ updateProgress(progressListener, R.string.parser_reading_done);
+
+ return result;
+ }
+
+} \ No newline at end of file
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/RandomSongsParser.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/RandomSongsParser.java
new file mode 100644
index 00000000..0bf422b7
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/RandomSongsParser.java
@@ -0,0 +1,62 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.service.parser;
+
+import android.content.Context;
+import net.sourceforge.subsonic.androidapp.R;
+import net.sourceforge.subsonic.androidapp.domain.MusicDirectory;
+import net.sourceforge.subsonic.androidapp.util.ProgressListener;
+import org.xmlpull.v1.XmlPullParser;
+
+import java.io.Reader;
+
+/**
+ * @author Sindre Mehus
+ */
+public class RandomSongsParser extends MusicDirectoryEntryParser {
+
+ public RandomSongsParser(Context context) {
+ super(context);
+ }
+
+ public MusicDirectory parse(Reader reader, ProgressListener progressListener) throws Exception {
+ updateProgress(progressListener, R.string.parser_reading);
+ init(reader);
+
+ MusicDirectory dir = new MusicDirectory();
+ int eventType;
+ do {
+ eventType = nextParseEvent();
+ if (eventType == XmlPullParser.START_TAG) {
+ String name = getElementName();
+ if ("song".equals(name)) {
+ dir.addChild(parseEntry());
+ } else if ("error".equals(name)) {
+ handleError();
+ }
+ }
+ } while (eventType != XmlPullParser.END_DOCUMENT);
+
+ validate();
+ updateProgress(progressListener, R.string.parser_reading_done);
+
+ return dir;
+ }
+
+} \ No newline at end of file
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/SearchResult2Parser.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/SearchResult2Parser.java
new file mode 100644
index 00000000..01052f25
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/SearchResult2Parser.java
@@ -0,0 +1,75 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.service.parser;
+
+import android.content.Context;
+import net.sourceforge.subsonic.androidapp.R;
+import net.sourceforge.subsonic.androidapp.domain.MusicDirectory;
+import net.sourceforge.subsonic.androidapp.domain.SearchResult;
+import net.sourceforge.subsonic.androidapp.domain.Artist;
+import net.sourceforge.subsonic.androidapp.util.ProgressListener;
+import org.xmlpull.v1.XmlPullParser;
+
+import java.io.Reader;
+import java.util.List;
+import java.util.ArrayList;
+
+/**
+ * @author Sindre Mehus
+ */
+public class SearchResult2Parser extends MusicDirectoryEntryParser {
+
+ public SearchResult2Parser(Context context) {
+ super(context);
+ }
+
+ public SearchResult parse(Reader reader, ProgressListener progressListener) throws Exception {
+ updateProgress(progressListener, R.string.parser_reading);
+ init(reader);
+
+ List<Artist> artists = new ArrayList<Artist>();
+ List<MusicDirectory.Entry> albums = new ArrayList<MusicDirectory.Entry>();
+ List<MusicDirectory.Entry> songs = new ArrayList<MusicDirectory.Entry>();
+ int eventType;
+ do {
+ eventType = nextParseEvent();
+ if (eventType == XmlPullParser.START_TAG) {
+ String name = getElementName();
+ if ("artist".equals(name)) {
+ Artist artist = new Artist();
+ artist.setId(get("id"));
+ artist.setName(get("name"));
+ artists.add(artist);
+ } else if ("album".equals(name)) {
+ albums.add(parseEntry());
+ } else if ("song".equals(name)) {
+ songs.add(parseEntry());
+ } else if ("error".equals(name)) {
+ handleError();
+ }
+ }
+ } while (eventType != XmlPullParser.END_DOCUMENT);
+
+ validate();
+ updateProgress(progressListener, R.string.parser_reading_done);
+
+ return new SearchResult(artists, albums, songs);
+ }
+
+} \ No newline at end of file
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/SearchResultParser.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/SearchResultParser.java
new file mode 100644
index 00000000..c38b077f
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/SearchResultParser.java
@@ -0,0 +1,67 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.service.parser;
+
+import android.content.Context;
+import net.sourceforge.subsonic.androidapp.R;
+import net.sourceforge.subsonic.androidapp.domain.MusicDirectory;
+import net.sourceforge.subsonic.androidapp.domain.SearchResult;
+import net.sourceforge.subsonic.androidapp.domain.Artist;
+import net.sourceforge.subsonic.androidapp.util.ProgressListener;
+import org.xmlpull.v1.XmlPullParser;
+
+import java.io.Reader;
+import java.util.Collections;
+import java.util.List;
+import java.util.ArrayList;
+
+/**
+ * @author Sindre Mehus
+ */
+public class SearchResultParser extends MusicDirectoryEntryParser {
+
+ public SearchResultParser(Context context) {
+ super(context);
+ }
+
+ public SearchResult parse(Reader reader, ProgressListener progressListener) throws Exception {
+ updateProgress(progressListener, R.string.parser_reading);
+ init(reader);
+
+ List<MusicDirectory.Entry> songs = new ArrayList<MusicDirectory.Entry>();
+ int eventType;
+ do {
+ eventType = nextParseEvent();
+ if (eventType == XmlPullParser.START_TAG) {
+ String name = getElementName();
+ if ("match".equals(name)) {
+ songs.add(parseEntry());
+ } else if ("error".equals(name)) {
+ handleError();
+ }
+ }
+ } while (eventType != XmlPullParser.END_DOCUMENT);
+
+ validate();
+ updateProgress(progressListener, R.string.parser_reading_done);
+
+ return new SearchResult(Collections.<Artist>emptyList(), Collections.<MusicDirectory.Entry>emptyList(), songs);
+ }
+
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/SubsonicRESTException.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/SubsonicRESTException.java
new file mode 100644
index 00000000..b46b6f22
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/SubsonicRESTException.java
@@ -0,0 +1,19 @@
+package net.sourceforge.subsonic.androidapp.service.parser;
+
+/**
+ * @author Sindre Mehus
+ * @version $Id$
+ */
+public class SubsonicRESTException extends Exception {
+
+ private final int code;
+
+ public SubsonicRESTException(int code, String message) {
+ super(message);
+ this.code = code;
+ }
+
+ public int getCode() {
+ return code;
+ }
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/VersionParser.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/VersionParser.java
new file mode 100644
index 00000000..b8a05531
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/VersionParser.java
@@ -0,0 +1,47 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.service.parser;
+
+import net.sourceforge.subsonic.androidapp.domain.Version;
+
+import java.io.BufferedReader;
+import java.io.Reader;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * @author Sindre Mehus
+ */
+public class VersionParser {
+
+ public Version parse(Reader reader) throws Exception {
+
+ BufferedReader bufferedReader = new BufferedReader(reader);
+ Pattern pattern = Pattern.compile("SUBSONIC_ANDROID_VERSION_BEGIN(.*)SUBSONIC_ANDROID_VERSION_END");
+ String line = bufferedReader.readLine();
+ while (line != null) {
+ Matcher finalMatcher = pattern.matcher(line);
+ if (finalMatcher.find()) {
+ return new Version(finalMatcher.group(1));
+ }
+ line = bufferedReader.readLine();
+ }
+ return null;
+ }
+} \ No newline at end of file
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/ssl/SSLSocketFactory.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/ssl/SSLSocketFactory.java
new file mode 100644
index 00000000..0e146650
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/ssl/SSLSocketFactory.java
@@ -0,0 +1,497 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * <http://www.apache.org/>.
+ *
+ */
+
+package net.sourceforge.subsonic.androidapp.service.ssl;
+
+import org.apache.http.conn.ConnectTimeoutException;
+import org.apache.http.conn.scheme.HostNameResolver;
+import org.apache.http.conn.scheme.LayeredSocketFactory;
+import org.apache.http.conn.ssl.AllowAllHostnameVerifier;
+import org.apache.http.conn.ssl.BrowserCompatHostnameVerifier;
+import org.apache.http.conn.ssl.StrictHostnameVerifier;
+import org.apache.http.conn.ssl.X509HostnameVerifier;
+import org.apache.http.params.HttpConnectionParams;
+import org.apache.http.params.HttpParams;
+
+import javax.net.ssl.HttpsURLConnection;
+import javax.net.ssl.KeyManager;
+import javax.net.ssl.KeyManagerFactory;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLSocket;
+import javax.net.ssl.TrustManager;
+import javax.net.ssl.TrustManagerFactory;
+import javax.net.ssl.X509TrustManager;
+
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.Socket;
+import java.net.SocketTimeoutException;
+import java.net.UnknownHostException;
+import java.security.KeyManagementException;
+import java.security.KeyStore;
+import java.security.KeyStoreException;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.security.UnrecoverableKeyException;
+
+/**
+ * Layered socket factory for TLS/SSL connections.
+ * <p>
+ * SSLSocketFactory can be used to validate the identity of the HTTPS server against a list of
+ * trusted certificates and to authenticate to the HTTPS server using a private key.
+ * <p>
+ * SSLSocketFactory will enable server authentication when supplied with
+ * a {@link KeyStore trust-store} file containing one or several trusted certificates. The client
+ * secure socket will reject the connection during the SSL session handshake if the target HTTPS
+ * server attempts to authenticate itself with a non-trusted certificate.
+ * <p>
+ * Use JDK keytool utility to import a trusted certificate and generate a trust-store file:
+ * <pre>
+ * keytool -import -alias "my server cert" -file server.crt -keystore my.truststore
+ * </pre>
+ * <p>
+ * In special cases the standard trust verification process can be bypassed by using a custom
+ * {@link TrustStrategy}. This interface is primarily intended for allowing self-signed
+ * certificates to be accepted as trusted without having to add them to the trust-store file.
+ * <p>
+ * The following parameters can be used to customize the behavior of this
+ * class:
+ * <ul>
+ * <li>{@link org.apache.http.params.CoreConnectionPNames#CONNECTION_TIMEOUT}</li>
+ * <li>{@link org.apache.http.params.CoreConnectionPNames#SO_TIMEOUT}</li>
+ * </ul>
+ * <p>
+ * SSLSocketFactory will enable client authentication when supplied with
+ * a {@link KeyStore key-store} file containing a private key/public certificate
+ * pair. The client secure socket will use the private key to authenticate
+ * itself to the target HTTPS server during the SSL session handshake if
+ * requested to do so by the server.
+ * The target HTTPS server will in its turn verify the certificate presented
+ * by the client in order to establish client's authenticity
+ * <p>
+ * Use the following sequence of actions to generate a key-store file
+ * </p>
+ * <ul>
+ * <li>
+ * <p>
+ * Use JDK keytool utility to generate a new key
+ * <pre>keytool -genkey -v -alias "my client key" -validity 365 -keystore my.keystore</pre>
+ * For simplicity use the same password for the key as that of the key-store
+ * </p>
+ * </li>
+ * <li>
+ * <p>
+ * Issue a certificate signing request (CSR)
+ * <pre>keytool -certreq -alias "my client key" -file mycertreq.csr -keystore my.keystore</pre>
+ * </p>
+ * </li>
+ * <li>
+ * <p>
+ * Send the certificate request to the trusted Certificate Authority for signature.
+ * One may choose to act as her own CA and sign the certificate request using a PKI
+ * tool, such as OpenSSL.
+ * </p>
+ * </li>
+ * <li>
+ * <p>
+ * Import the trusted CA root certificate
+ * <pre>keytool -import -alias "my trusted ca" -file caroot.crt -keystore my.keystore</pre>
+ * </p>
+ * </li>
+ * <li>
+ * <p>
+ * Import the PKCS#7 file containg the complete certificate chain
+ * <pre>keytool -import -alias "my client key" -file mycert.p7 -keystore my.keystore</pre>
+ * </p>
+ * </li>
+ * <li>
+ * <p>
+ * Verify the content the resultant keystore file
+ * <pre>keytool -list -v -keystore my.keystore</pre>
+ * </p>
+ * </li>
+ * </ul>
+ *
+ * @since 4.0
+ */
+public class SSLSocketFactory implements LayeredSocketFactory {
+
+ public static final String TLS = "TLS";
+ public static final String SSL = "SSL";
+ public static final String SSLV2 = "SSLv2";
+
+ public static final X509HostnameVerifier ALLOW_ALL_HOSTNAME_VERIFIER
+ = new AllowAllHostnameVerifier();
+
+ public static final X509HostnameVerifier BROWSER_COMPATIBLE_HOSTNAME_VERIFIER
+ = new BrowserCompatHostnameVerifier();
+
+ public static final X509HostnameVerifier STRICT_HOSTNAME_VERIFIER
+ = new StrictHostnameVerifier();
+
+ /**
+ * The default factory using the default JVM settings for secure connections.
+ */
+ private static final SSLSocketFactory DEFAULT_FACTORY = new SSLSocketFactory();
+
+ /**
+ * Gets the default factory, which uses the default JVM settings for secure
+ * connections.
+ *
+ * @return the default factory
+ */
+ public static SSLSocketFactory getSocketFactory() {
+ return DEFAULT_FACTORY;
+ }
+
+ private final javax.net.ssl.SSLSocketFactory socketfactory;
+ private final HostNameResolver nameResolver;
+ // TODO: make final
+ private volatile X509HostnameVerifier hostnameVerifier;
+
+ private static SSLContext createSSLContext(
+ String algorithm,
+ final KeyStore keystore,
+ final String keystorePassword,
+ final KeyStore truststore,
+ final SecureRandom random,
+ final TrustStrategy trustStrategy)
+ throws NoSuchAlgorithmException, KeyStoreException, UnrecoverableKeyException, KeyManagementException {
+ if (algorithm == null) {
+ algorithm = TLS;
+ }
+ KeyManagerFactory kmfactory = KeyManagerFactory.getInstance(
+ KeyManagerFactory.getDefaultAlgorithm());
+ kmfactory.init(keystore, keystorePassword != null ? keystorePassword.toCharArray(): null);
+ KeyManager[] keymanagers = kmfactory.getKeyManagers();
+ TrustManagerFactory tmfactory = TrustManagerFactory.getInstance(
+ TrustManagerFactory.getDefaultAlgorithm());
+ tmfactory.init(keystore);
+ TrustManager[] trustmanagers = tmfactory.getTrustManagers();
+ if (trustmanagers != null && trustStrategy != null) {
+ for (int i = 0; i < trustmanagers.length; i++) {
+ TrustManager tm = trustmanagers[i];
+ if (tm instanceof X509TrustManager) {
+ trustmanagers[i] = new TrustManagerDecorator(
+ (X509TrustManager) tm, trustStrategy);
+ }
+ }
+ }
+
+ SSLContext sslcontext = SSLContext.getInstance(algorithm);
+ sslcontext.init(keymanagers, trustmanagers, random);
+ return sslcontext;
+ }
+
+ /**
+ * @deprecated Use {@link #SSLSocketFactory(String, KeyStore, String, KeyStore, SecureRandom, X509HostnameVerifier)}
+ */
+ @Deprecated
+ public SSLSocketFactory(
+ final String algorithm,
+ final KeyStore keystore,
+ final String keystorePassword,
+ final KeyStore truststore,
+ final SecureRandom random,
+ final HostNameResolver nameResolver)
+ throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException, UnrecoverableKeyException {
+ this(createSSLContext(
+ algorithm, keystore, keystorePassword, truststore, random, null),
+ nameResolver);
+ }
+
+ /**
+ * @since 4.1
+ */
+ public SSLSocketFactory(
+ String algorithm,
+ final KeyStore keystore,
+ final String keystorePassword,
+ final KeyStore truststore,
+ final SecureRandom random,
+ final X509HostnameVerifier hostnameVerifier)
+ throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException, UnrecoverableKeyException {
+ this(createSSLContext(
+ algorithm, keystore, keystorePassword, truststore, random, null),
+ hostnameVerifier);
+ }
+
+ /**
+ * @since 4.1
+ */
+ public SSLSocketFactory(
+ String algorithm,
+ final KeyStore keystore,
+ final String keystorePassword,
+ final KeyStore truststore,
+ final SecureRandom random,
+ final TrustStrategy trustStrategy,
+ final X509HostnameVerifier hostnameVerifier)
+ throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException, UnrecoverableKeyException {
+ this(createSSLContext(
+ algorithm, keystore, keystorePassword, truststore, random, trustStrategy),
+ hostnameVerifier);
+ }
+
+ public SSLSocketFactory(
+ final KeyStore keystore,
+ final String keystorePassword,
+ final KeyStore truststore)
+ throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException, UnrecoverableKeyException {
+ this(TLS, keystore, keystorePassword, truststore, null, null, BROWSER_COMPATIBLE_HOSTNAME_VERIFIER);
+ }
+
+ public SSLSocketFactory(
+ final KeyStore keystore,
+ final String keystorePassword)
+ throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException, UnrecoverableKeyException{
+ this(TLS, keystore, keystorePassword, null, null, null, BROWSER_COMPATIBLE_HOSTNAME_VERIFIER);
+ }
+
+ public SSLSocketFactory(
+ final KeyStore truststore)
+ throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException, UnrecoverableKeyException {
+ this(TLS, null, null, truststore, null, null, BROWSER_COMPATIBLE_HOSTNAME_VERIFIER);
+ }
+
+ /**
+ * @since 4.1
+ */
+ public SSLSocketFactory(
+ final TrustStrategy trustStrategy,
+ final X509HostnameVerifier hostnameVerifier)
+ throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException, UnrecoverableKeyException {
+ this(TLS, null, null, null, null, trustStrategy, hostnameVerifier);
+ }
+
+ /**
+ * @since 4.1
+ */
+ public SSLSocketFactory(
+ final TrustStrategy trustStrategy)
+ throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException, UnrecoverableKeyException {
+ this(TLS, null, null, null, null, trustStrategy, BROWSER_COMPATIBLE_HOSTNAME_VERIFIER);
+ }
+
+ public SSLSocketFactory(final SSLContext sslContext) {
+ this(sslContext, BROWSER_COMPATIBLE_HOSTNAME_VERIFIER);
+ }
+
+ /**
+ * @deprecated Use {@link #SSLSocketFactory(SSLContext)}
+ */
+ @Deprecated
+ public SSLSocketFactory(
+ final SSLContext sslContext, final HostNameResolver nameResolver) {
+ super();
+ this.socketfactory = sslContext.getSocketFactory();
+ this.hostnameVerifier = BROWSER_COMPATIBLE_HOSTNAME_VERIFIER;
+ this.nameResolver = nameResolver;
+ }
+
+ /**
+ * @since 4.1
+ */
+ public SSLSocketFactory(
+ final SSLContext sslContext, final X509HostnameVerifier hostnameVerifier) {
+ super();
+ this.socketfactory = sslContext.getSocketFactory();
+ this.hostnameVerifier = hostnameVerifier;
+ this.nameResolver = null;
+ }
+
+ private SSLSocketFactory() {
+ super();
+ this.socketfactory = HttpsURLConnection.getDefaultSSLSocketFactory();
+ this.hostnameVerifier = null;
+ this.nameResolver = null;
+ }
+
+ /**
+ * @param params Optional parameters. Parameters passed to this method will have no effect.
+ * This method will create a unconnected instance of {@link Socket} class
+ * using {@link javax.net.ssl.SSLSocketFactory#createSocket()} method.
+ * @since 4.1
+ */
+ @SuppressWarnings("cast")
+ public Socket createSocket(final HttpParams params) throws IOException {
+ // the cast makes sure that the factory is working as expected
+ return (SSLSocket) this.socketfactory.createSocket();
+ }
+
+ @SuppressWarnings("cast")
+ public Socket createSocket() throws IOException {
+ // the cast makes sure that the factory is working as expected
+ return (SSLSocket) this.socketfactory.createSocket();
+ }
+
+ /**
+ * @since 4.1
+ */
+ public Socket connectSocket(
+ final Socket sock,
+ final InetSocketAddress remoteAddress,
+ final InetSocketAddress localAddress,
+ final HttpParams params) throws IOException, UnknownHostException, ConnectTimeoutException {
+ if (remoteAddress == null) {
+ throw new IllegalArgumentException("Remote address may not be null");
+ }
+ if (params == null) {
+ throw new IllegalArgumentException("HTTP parameters may not be null");
+ }
+ SSLSocket sslsock = (SSLSocket) (sock != null ? sock : createSocket());
+ if (localAddress != null) {
+// sslsock.setReuseAddress(HttpConnectionParams.getSoReuseaddr(params));
+ sslsock.bind(localAddress);
+ }
+
+ int connTimeout = HttpConnectionParams.getConnectionTimeout(params);
+ int soTimeout = HttpConnectionParams.getSoTimeout(params);
+
+ try {
+ sslsock.connect(remoteAddress, connTimeout);
+ } catch (SocketTimeoutException ex) {
+ throw new ConnectTimeoutException("Connect to " + remoteAddress.getHostName() + "/"
+ + remoteAddress.getAddress() + " timed out");
+ }
+ sslsock.setSoTimeout(soTimeout);
+ if (this.hostnameVerifier != null) {
+ try {
+ this.hostnameVerifier.verify(remoteAddress.getHostName(), sslsock);
+ // verifyHostName() didn't blowup - good!
+ } catch (IOException iox) {
+ // close the socket before re-throwing the exception
+ try { sslsock.close(); } catch (Exception x) { /*ignore*/ }
+ throw iox;
+ }
+ }
+ return sslsock;
+ }
+
+
+ /**
+ * Checks whether a socket connection is secure.
+ * This factory creates TLS/SSL socket connections
+ * which, by default, are considered secure.
+ * <br/>
+ * Derived classes may override this method to perform
+ * runtime checks, for example based on the cypher suite.
+ *
+ * @param sock the connected socket
+ *
+ * @return <code>true</code>
+ *
+ * @throws IllegalArgumentException if the argument is invalid
+ */
+ public boolean isSecure(final Socket sock) throws IllegalArgumentException {
+ if (sock == null) {
+ throw new IllegalArgumentException("Socket may not be null");
+ }
+ // This instanceof check is in line with createSocket() above.
+ if (!(sock instanceof SSLSocket)) {
+ throw new IllegalArgumentException("Socket not created by this factory");
+ }
+ // This check is performed last since it calls the argument object.
+ if (sock.isClosed()) {
+ throw new IllegalArgumentException("Socket is closed");
+ }
+ return true;
+ }
+
+ /**
+ * @since 4.1
+ */
+ public Socket createLayeredSocket(
+ final Socket socket,
+ final String host,
+ final int port,
+ final boolean autoClose) throws IOException, UnknownHostException {
+ SSLSocket sslSocket = (SSLSocket) this.socketfactory.createSocket(
+ socket,
+ host,
+ port,
+ autoClose
+ );
+ if (this.hostnameVerifier != null) {
+ this.hostnameVerifier.verify(host, sslSocket);
+ }
+ // verifyHostName() didn't blowup - good!
+ return sslSocket;
+ }
+
+ @Deprecated
+ public void setHostnameVerifier(X509HostnameVerifier hostnameVerifier) {
+ if ( hostnameVerifier == null ) {
+ throw new IllegalArgumentException("Hostname verifier may not be null");
+ }
+ this.hostnameVerifier = hostnameVerifier;
+ }
+
+ public X509HostnameVerifier getHostnameVerifier() {
+ return this.hostnameVerifier;
+ }
+
+ /**
+ * @deprecated Use {@link #connectSocket(Socket, InetSocketAddress, InetSocketAddress, HttpParams)}
+ */
+ @Deprecated
+ public Socket connectSocket(
+ final Socket socket,
+ final String host, int port,
+ final InetAddress localAddress, int localPort,
+ final HttpParams params) throws IOException, UnknownHostException, ConnectTimeoutException {
+ InetSocketAddress local = null;
+ if (localAddress != null || localPort > 0) {
+ // we need to bind explicitly
+ if (localPort < 0) {
+ localPort = 0; // indicates "any"
+ }
+ local = new InetSocketAddress(localAddress, localPort);
+ }
+ InetAddress remoteAddress;
+ if (this.nameResolver != null) {
+ remoteAddress = this.nameResolver.resolve(host);
+ } else {
+ remoteAddress = InetAddress.getByName(host);
+ }
+ InetSocketAddress remote = new InetSocketAddress(remoteAddress, port);
+ return connectSocket(socket, remote, local, params);
+ }
+
+ /**
+ * @deprecated Use {@link #createLayeredSocket(Socket, String, int, boolean)}
+ */
+ @Deprecated
+ public Socket createSocket(
+ final Socket socket,
+ final String host, int port,
+ boolean autoClose) throws IOException, UnknownHostException {
+ return createLayeredSocket(socket, host, port, autoClose);
+ }
+
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/ssl/TrustManagerDecorator.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/ssl/TrustManagerDecorator.java
new file mode 100644
index 00000000..41d98249
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/ssl/TrustManagerDecorator.java
@@ -0,0 +1,65 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * <http://www.apache.org/>.
+ *
+ */
+package net.sourceforge.subsonic.androidapp.service.ssl;
+
+import java.security.cert.CertificateException;
+import java.security.cert.X509Certificate;
+
+import javax.net.ssl.X509TrustManager;
+
+
+/**
+ * @since 4.1
+ */
+class TrustManagerDecorator implements X509TrustManager {
+
+ private final X509TrustManager trustManager;
+ private final TrustStrategy trustStrategy;
+
+ TrustManagerDecorator(final X509TrustManager trustManager, final TrustStrategy trustStrategy) {
+ super();
+ this.trustManager = trustManager;
+ this.trustStrategy = trustStrategy;
+ }
+
+ public void checkClientTrusted(
+ final X509Certificate[] chain, final String authType) throws CertificateException {
+ this.trustManager.checkClientTrusted(chain, authType);
+ }
+
+ public void checkServerTrusted(
+ final X509Certificate[] chain, final String authType) throws CertificateException {
+ if (!this.trustStrategy.isTrusted(chain, authType)) {
+ this.trustManager.checkServerTrusted(chain, authType);
+ }
+ }
+
+ public X509Certificate[] getAcceptedIssuers() {
+ return this.trustManager.getAcceptedIssuers();
+ }
+
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/ssl/TrustSelfSignedStrategy.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/ssl/TrustSelfSignedStrategy.java
new file mode 100644
index 00000000..4fdaaba2
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/ssl/TrustSelfSignedStrategy.java
@@ -0,0 +1,44 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * <http://www.apache.org/>.
+ *
+ */
+package net.sourceforge.subsonic.androidapp.service.ssl;
+
+import java.security.cert.CertificateException;
+import java.security.cert.X509Certificate;
+
+/**
+ * A trust strategy that accepts self-signed certificates as trusted. Verification of all other
+ * certificates is done by the trust manager configured in the SSL context.
+ *
+ * @since 4.1
+ */
+public class TrustSelfSignedStrategy implements TrustStrategy {
+
+ public boolean isTrusted(final X509Certificate[] chain, final String authType) throws CertificateException {
+ return true;
+ }
+
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/ssl/TrustStrategy.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/ssl/TrustStrategy.java
new file mode 100644
index 00000000..3cf75b68
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/ssl/TrustStrategy.java
@@ -0,0 +1,57 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * <http://www.apache.org/>.
+ *
+ */
+package net.sourceforge.subsonic.androidapp.service.ssl;
+
+import java.security.cert.CertificateException;
+import java.security.cert.X509Certificate;
+
+/**
+ * A strategy to establish trustworthiness of certificates without consulting the trust manager
+ * configured in the actual SSL context. This interface can be used to override the standard
+ * JSSE certificate verification process.
+ *
+ * @since 4.1
+ */
+public interface TrustStrategy {
+
+ /**
+ * Determines whether the certificate chain can be trusted without consulting the trust manager
+ * configured in the actual SSL context. This method can be used to override the standard JSSE
+ * certificate verification process.
+ * <p>
+ * Please note that, if this method returns <code>false</code>, the trust manager configured
+ * in the actual SSL context can still clear the certificate as trusted.
+ *
+ * @param chain the peer certificate chain
+ * @param authType the authentication type based on the client certificate
+ * @return <code>true</code> if the certificate can be trusted without verification by
+ * the trust manager, <code>false</code> otherwise.
+ * @throws CertificateException thrown if the certificate is not trusted or invalid.
+ */
+ boolean isTrusted(X509Certificate[] chain, String authType) throws CertificateException;
+
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/AlbumView.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/AlbumView.java
new file mode 100644
index 00000000..a4dd3acd
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/AlbumView.java
@@ -0,0 +1,55 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.util;
+
+import android.content.Context;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+import net.sourceforge.subsonic.androidapp.R;
+import net.sourceforge.subsonic.androidapp.domain.MusicDirectory;
+
+/**
+ * Used to display albums in a {@code ListView}.
+ *
+ * @author Sindre Mehus
+ */
+public class AlbumView extends LinearLayout {
+
+ private TextView titleView;
+ private TextView artistView;
+ private View coverArtView;
+
+ public AlbumView(Context context) {
+ super(context);
+ LayoutInflater.from(context).inflate(R.layout.album_list_item, this, true);
+
+ titleView = (TextView) findViewById(R.id.album_title);
+ artistView = (TextView) findViewById(R.id.album_artist);
+ coverArtView = findViewById(R.id.album_coverart);
+ }
+
+ public void setAlbum(MusicDirectory.Entry album, ImageLoader imageLoader) {
+ titleView.setText(album.getTitle());
+ artistView.setText(album.getArtist());
+ artistView.setVisibility(album.getArtist() == null ? View.GONE : View.VISIBLE);
+ imageLoader.loadImage(coverArtView, album, false, true);
+ }
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/ArtistAdapter.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/ArtistAdapter.java
new file mode 100644
index 00000000..98ed3c9b
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/ArtistAdapter.java
@@ -0,0 +1,78 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2010 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.util;
+
+import net.sourceforge.subsonic.androidapp.domain.Artist;
+import net.sourceforge.subsonic.androidapp.R;
+import android.widget.ArrayAdapter;
+import android.widget.SectionIndexer;
+import android.content.Context;
+
+import java.util.List;
+import java.util.Set;
+import java.util.LinkedHashSet;
+import java.util.ArrayList;
+
+/**
+ * @author Sindre Mehus
+*/
+public class ArtistAdapter extends ArrayAdapter<Artist> implements SectionIndexer {
+
+ // Both arrays are indexed by section ID.
+ private final Object[] sections;
+ private final Integer[] positions;
+
+ public ArtistAdapter(Context context, List<Artist> artists) {
+ super(context, R.layout.artist_list_item, artists);
+
+ Set<String> sectionSet = new LinkedHashSet<String>(30);
+ List<Integer> positionList = new ArrayList<Integer>(30);
+ for (int i = 0; i < artists.size(); i++) {
+ Artist artist = artists.get(i);
+ String index = artist.getIndex();
+ if (!sectionSet.contains(index)) {
+ sectionSet.add(index);
+ positionList.add(i);
+ }
+ }
+ sections = sectionSet.toArray(new Object[sectionSet.size()]);
+ positions = positionList.toArray(new Integer[positionList.size()]);
+ }
+
+ @Override
+ public Object[] getSections() {
+ return sections;
+ }
+
+ @Override
+ public int getPositionForSection(int section) {
+ section = Math.min(section, positions.length - 1);
+ return positions[section];
+ }
+
+ @Override
+ public int getSectionForPosition(int pos) {
+ for (int i = 0; i < sections.length - 1; i++) {
+ if (pos < positions[i + 1]) {
+ return i;
+ }
+ }
+ return sections.length - 1;
+ }
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/BackgroundTask.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/BackgroundTask.java
new file mode 100644
index 00000000..1db2fdc1
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/BackgroundTask.java
@@ -0,0 +1,96 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.util;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+
+import org.xmlpull.v1.XmlPullParserException;
+
+import android.app.Activity;
+import android.os.Handler;
+import android.util.Log;
+import net.sourceforge.subsonic.androidapp.R;
+
+/**
+ * @author Sindre Mehus
+ */
+public abstract class BackgroundTask<T> implements ProgressListener {
+
+ private static final String TAG = BackgroundTask.class.getSimpleName();
+ private final Activity activity;
+ private final Handler handler;
+
+ public BackgroundTask(Activity activity) {
+ this.activity = activity;
+ handler = new Handler();
+ }
+
+ protected Activity getActivity() {
+ return activity;
+ }
+
+ protected Handler getHandler() {
+ return handler;
+ }
+
+ public abstract void execute();
+
+ protected abstract T doInBackground() throws Throwable;
+
+ protected abstract void done(T result);
+
+ protected void error(Throwable error) {
+ Log.w(TAG, "Got exception: " + error, error);
+ new ErrorDialog(activity, getErrorMessage(error), true);
+ }
+
+ protected String getErrorMessage(Throwable error) {
+
+ if (error instanceof IOException && !Util.isNetworkConnected(activity)) {
+ return activity.getResources().getString(R.string.background_task_no_network);
+ }
+
+ if (error instanceof FileNotFoundException) {
+ return activity.getResources().getString(R.string.background_task_not_found);
+ }
+
+ if (error instanceof IOException) {
+ return activity.getResources().getString(R.string.background_task_network_error);
+ }
+
+ if (error instanceof XmlPullParserException) {
+ return activity.getResources().getString(R.string.background_task_parse_error);
+ }
+
+ String message = error.getMessage();
+ if (message != null) {
+ return message;
+ }
+ return error.getClass().getSimpleName();
+ }
+
+ @Override
+ public abstract void updateProgress(final String message);
+
+ @Override
+ public void updateProgress(int messageId) {
+ updateProgress(activity.getResources().getString(messageId));
+ }
+} \ No newline at end of file
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/CacheCleaner.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/CacheCleaner.java
new file mode 100644
index 00000000..46459571
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/CacheCleaner.java
@@ -0,0 +1,171 @@
+package net.sourceforge.subsonic.androidapp.util;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import android.content.Context;
+import android.util.Log;
+import android.os.StatFs;
+import net.sourceforge.subsonic.androidapp.service.DownloadFile;
+import net.sourceforge.subsonic.androidapp.service.DownloadService;
+
+/**
+ * @author Sindre Mehus
+ * @version $Id$
+ */
+public class CacheCleaner {
+
+ private static final String TAG = CacheCleaner.class.getSimpleName();
+ private static final double MAX_FILE_SYSTEM_USAGE = 0.95;
+
+ private final Context context;
+ private final DownloadService downloadService;
+
+ public CacheCleaner(Context context, DownloadService downloadService) {
+ this.context = context;
+ this.downloadService = downloadService;
+ }
+
+ public void clean() {
+
+ Log.i(TAG, "Starting cache cleaning.");
+
+ if (downloadService == null) {
+ Log.e(TAG, "DownloadService not set. Aborting cache cleaning.");
+ return;
+ }
+
+ try {
+
+ List<File> files = new ArrayList<File>();
+ List<File> dirs = new ArrayList<File>();
+
+ findCandidatesForDeletion(FileUtil.getMusicDirectory(context), files, dirs);
+ sortByAscendingModificationTime(files);
+
+ Set<File> undeletable = findUndeletableFiles();
+
+ deleteFiles(files, undeletable);
+ deleteEmptyDirs(dirs, undeletable);
+ Log.i(TAG, "Completed cache cleaning.");
+
+ } catch (RuntimeException x) {
+ Log.e(TAG, "Error in cache cleaning.", x);
+ }
+ }
+
+ private void deleteEmptyDirs(List<File> dirs, Set<File> undeletable) {
+ for (File dir : dirs) {
+ if (undeletable.contains(dir)) {
+ continue;
+ }
+
+ File[] children = dir.listFiles();
+
+ // Delete empty directory and associated album artwork.
+ if (children.length == 0) {
+ Util.delete(dir);
+ Util.delete(FileUtil.getAlbumArtFile(dir));
+ }
+ }
+ }
+
+ private void deleteFiles(List<File> files, Set<File> undeletable) {
+
+ if (files.isEmpty()) {
+ return;
+ }
+
+ long cacheSizeBytes = Util.getCacheSizeMB(context) * 1024L * 1024L;
+
+ long bytesUsedBySubsonic = 0L;
+ for (File file : files) {
+ bytesUsedBySubsonic += file.length();
+ }
+
+ // Ensure that file system is not more than 95% full.
+ StatFs stat = new StatFs(files.get(0).getPath());
+ long bytesTotalFs = (long) stat.getBlockCount() * (long) stat.getBlockSize();
+ long bytesAvailableFs = (long) stat.getAvailableBlocks() * (long) stat.getBlockSize();
+ long bytesUsedFs = bytesTotalFs - bytesAvailableFs;
+ long minFsAvailability = Math.round(MAX_FILE_SYSTEM_USAGE * (double) bytesTotalFs);
+
+ long bytesToDeleteCacheLimit = Math.max(bytesUsedBySubsonic - cacheSizeBytes, 0L);
+ long bytesToDeleteFsLimit = Math.max(bytesUsedFs - minFsAvailability, 0L);
+ long bytesToDelete = Math.max(bytesToDeleteCacheLimit, bytesToDeleteFsLimit);
+
+ Log.i(TAG, "File system : " + Util.formatBytes(bytesAvailableFs) + " of " + Util.formatBytes(bytesTotalFs) + " available");
+ Log.i(TAG, "Cache limit : " + Util.formatBytes(cacheSizeBytes));
+ Log.i(TAG, "Cache size before : " + Util.formatBytes(bytesUsedBySubsonic));
+ Log.i(TAG, "Minimum to delete : " + Util.formatBytes(bytesToDelete));
+
+ long bytesDeleted = 0L;
+ for (File file : files) {
+
+ if (file.getName().equals(Constants.ALBUM_ART_FILE)) {
+ // Move artwork to new folder.
+ file.renameTo(FileUtil.getAlbumArtFile(file.getParentFile()));
+
+ } else if (bytesToDelete > bytesDeleted || file.getName().endsWith(".partial") || file.getName().contains(".partial.")) {
+ if (!undeletable.contains(file)) {
+ long size = file.length();
+ if (Util.delete(file)) {
+ bytesDeleted += size;
+ }
+ }
+ }
+ }
+
+ Log.i(TAG, "Deleted : " + Util.formatBytes(bytesDeleted));
+ Log.i(TAG, "Cache size after : " + Util.formatBytes(bytesUsedBySubsonic - bytesDeleted));
+ }
+
+ private void findCandidatesForDeletion(File file, List<File> files, List<File> dirs) {
+ if (file.isFile()) {
+ String name = file.getName();
+ boolean isCacheFile = name.endsWith(".partial") || name.contains(".partial.") || name.endsWith(".complete") || name.contains(".complete.");
+ boolean isAlbumArtFile = name.equals(Constants.ALBUM_ART_FILE);
+ if (isCacheFile || isAlbumArtFile) {
+ files.add(file);
+ }
+ } else {
+ // Depth-first
+ for (File child : FileUtil.listFiles(file)) {
+ findCandidatesForDeletion(child, files, dirs);
+ }
+ dirs.add(file);
+ }
+ }
+
+ private void sortByAscendingModificationTime(List<File> files) {
+ Collections.sort(files, new Comparator<File>() {
+ @Override
+ public int compare(File a, File b) {
+ if (a.lastModified() < b.lastModified()) {
+ return -1;
+ }
+ if (a.lastModified() > b.lastModified()) {
+ return 1;
+ }
+ return 0;
+ }
+ });
+ }
+
+ private Set<File> findUndeletableFiles() {
+ Set<File> undeletable = new HashSet<File>(5);
+
+ for (DownloadFile downloadFile : downloadService.getDownloads()) {
+ undeletable.add(downloadFile.getPartialFile());
+ undeletable.add(downloadFile.getCompleteFile());
+ }
+
+ undeletable.add(FileUtil.getMusicDirectory(context));
+ return undeletable;
+ }
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/CancellableTask.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/CancellableTask.java
new file mode 100644
index 00000000..9c8b06e1
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/CancellableTask.java
@@ -0,0 +1,87 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.util;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+import android.util.Log;
+
+/**
+ * @author Sindre Mehus
+ * @version $Id$
+ */
+public abstract class CancellableTask {
+
+ private static final String TAG = CancellableTask.class.getSimpleName();
+
+ private final AtomicBoolean running = new AtomicBoolean(false);
+ private final AtomicBoolean cancelled = new AtomicBoolean(false);
+ private final AtomicReference<Thread> thread = new AtomicReference<Thread>();
+ private final AtomicReference<OnCancelListener> cancelListener = new AtomicReference<OnCancelListener>();
+
+ public void cancel() {
+ Log.d(TAG, "Cancelling " + CancellableTask.this);
+ cancelled.set(true);
+
+ OnCancelListener listener = cancelListener.get();
+ if (listener != null) {
+ try {
+ listener.onCancel();
+ } catch (Throwable x) {
+ Log.w(TAG, "Error when invoking OnCancelListener.", x);
+ }
+ }
+ }
+
+ public boolean isCancelled() {
+ return cancelled.get();
+ }
+
+ public void setOnCancelListener(OnCancelListener listener) {
+ cancelListener.set(listener);
+ }
+
+ public boolean isRunning() {
+ return running.get();
+ }
+
+ public abstract void execute();
+
+ public void start() {
+ thread.set(new Thread() {
+ @Override
+ public void run() {
+ running.set(true);
+ Log.d(TAG, "Starting thread for " + CancellableTask.this);
+ try {
+ execute();
+ } finally {
+ running.set(false);
+ Log.d(TAG, "Stopping thread for " + CancellableTask.this);
+ }
+ }
+ });
+ thread.get().start();
+ }
+
+ public static interface OnCancelListener {
+ void onCancel();
+ }
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/Constants.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/Constants.java
new file mode 100644
index 00000000..bebe49ce
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/Constants.java
@@ -0,0 +1,91 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.util;
+
+/**
+ * @author Sindre Mehus
+ * @version $Id$
+ */
+public final class Constants {
+
+ // Character encoding used throughout.
+ public static final String UTF_8 = "UTF-8";
+
+ // REST protocol version and client ID.
+ // Note: Keep it as low as possible to maintain compatibility with older servers.
+ public static final String REST_PROTOCOL_VERSION = "1.2.0";
+ public static final String REST_CLIENT_ID = "android";
+
+ // Names for intent extras.
+ public static final String INTENT_EXTRA_NAME_ID = "subsonic.id";
+ public static final String INTENT_EXTRA_NAME_NAME = "subsonic.name";
+ public static final String INTENT_EXTRA_NAME_ARTIST = "subsonic.artist";
+ public static final String INTENT_EXTRA_NAME_TITLE = "subsonic.title";
+ public static final String INTENT_EXTRA_NAME_AUTOPLAY = "subsonic.playall";
+ public static final String INTENT_EXTRA_NAME_ERROR = "subsonic.error";
+ public static final String INTENT_EXTRA_NAME_QUERY = "subsonic.query";
+ public static final String INTENT_EXTRA_NAME_PLAYLIST_ID = "subsonic.playlist.id";
+ public static final String INTENT_EXTRA_NAME_PLAYLIST_NAME = "subsonic.playlist.name";
+ public static final String INTENT_EXTRA_NAME_ALBUM_LIST_TYPE = "subsonic.albumlisttype";
+ public static final String INTENT_EXTRA_NAME_ALBUM_LIST_SIZE = "subsonic.albumlistsize";
+ public static final String INTENT_EXTRA_NAME_ALBUM_LIST_OFFSET = "subsonic.albumlistoffset";
+ public static final String INTENT_EXTRA_NAME_SHUFFLE = "subsonic.shuffle";
+ public static final String INTENT_EXTRA_NAME_REFRESH = "subsonic.refresh";
+ public static final String INTENT_EXTRA_REQUEST_SEARCH = "subsonic.requestsearch";
+ public static final String INTENT_EXTRA_NAME_EXIT = "subsonic.exit" ;
+
+ // Notification IDs.
+ public static final int NOTIFICATION_ID_PLAYING = 100;
+ public static final int NOTIFICATION_ID_ERROR = 101;
+
+ // Preferences keys.
+ public static final String PREFERENCES_KEY_SERVER_INSTANCE = "serverInstanceId";
+ public static final String PREFERENCES_KEY_SERVER_NAME = "serverName";
+ public static final String PREFERENCES_KEY_SERVER_URL = "serverUrl";
+ public static final String PREFERENCES_KEY_MUSIC_FOLDER_ID = "musicFolderId";
+ public static final String PREFERENCES_KEY_USERNAME = "username";
+ public static final String PREFERENCES_KEY_PASSWORD = "password";
+ public static final String PREFERENCES_KEY_INSTALL_TIME = "installTime";
+ public static final String PREFERENCES_KEY_THEME = "theme";
+ public static final String PREFERENCES_KEY_MAX_BITRATE_WIFI = "maxBitrateWifi";
+ public static final String PREFERENCES_KEY_MAX_BITRATE_MOBILE = "maxBitrateMobile";
+ public static final String PREFERENCES_KEY_CACHE_SIZE = "cacheSize";
+ public static final String PREFERENCES_KEY_CACHE_LOCATION = "cacheLocation";
+ public static final String PREFERENCES_KEY_PRELOAD_COUNT = "preloadCount";
+ public static final String PREFERENCES_KEY_HIDE_MEDIA = "hideMedia";
+ public static final String PREFERENCES_KEY_MEDIA_BUTTONS = "mediaButtons";
+ public static final String PREFERENCES_KEY_SCREEN_LIT_ON_DOWNLOAD = "screenLitOnDownload";
+ public static final String PREFERENCES_KEY_SCROBBLE = "scrobble";
+ public static final String PREFERENCES_KEY_REPEAT_MODE = "repeatMode";
+ public static final String PREFERENCES_KEY_WIFI_REQUIRED_FOR_DOWNLOAD = "wifiRequiredForDownload";
+
+ // Name of the preferences file.
+ public static final String PREFERENCES_FILE_NAME = "net.sourceforge.subsonic.androidapp_preferences";
+
+ // Number of free trial days for non-licensed servers.
+ public static final int FREE_TRIAL_DAYS = 30;
+
+ // URL for project donations.
+ public static final String DONATION_URL = "http://subsonic.org/pages/android-donation.jsp";
+
+ public static final String ALBUM_ART_FILE = "folder.jpeg";
+
+ private Constants() {
+ }
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/EntryAdapter.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/EntryAdapter.java
new file mode 100644
index 00000000..1b4d72cf
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/EntryAdapter.java
@@ -0,0 +1,71 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2010 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.util;
+
+import java.util.List;
+
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ArrayAdapter;
+import net.sourceforge.subsonic.androidapp.activity.SubsonicTabActivity;
+import net.sourceforge.subsonic.androidapp.domain.MusicDirectory;
+
+/**
+ * @author Sindre Mehus
+ */
+public class EntryAdapter extends ArrayAdapter<MusicDirectory.Entry> {
+
+ private final SubsonicTabActivity activity;
+ private final ImageLoader imageLoader;
+ private final boolean checkable;
+
+ public EntryAdapter(SubsonicTabActivity activity, ImageLoader imageLoader, List<MusicDirectory.Entry> entries, boolean checkable) {
+ super(activity, android.R.layout.simple_list_item_1, entries);
+ this.activity = activity;
+ this.imageLoader = imageLoader;
+ this.checkable = checkable;
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ MusicDirectory.Entry entry = getItem(position);
+
+ if (entry.isDirectory()) {
+ AlbumView view;
+ // TODO: Reuse AlbumView objects once cover art loading is working.
+// if (convertView != null && convertView instanceof AlbumView) {
+// view = (AlbumView) convertView;
+// } else {
+ view = new AlbumView(activity);
+// }
+ view.setAlbum(entry, imageLoader);
+ return view;
+
+ } else {
+ SongView view;
+ if (convertView != null && convertView instanceof SongView) {
+ view = (SongView) convertView;
+ } else {
+ view = new SongView(activity);
+ }
+ view.setSong(entry, checkable);
+ return view;
+ }
+ }
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/ErrorDialog.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/ErrorDialog.java
new file mode 100644
index 00000000..b1c51573
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/ErrorDialog.java
@@ -0,0 +1,61 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.util;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.content.DialogInterface;
+import net.sourceforge.subsonic.androidapp.R;
+
+/**
+ * @author Sindre Mehus
+ */
+public class ErrorDialog {
+
+ public ErrorDialog(Activity activity, int messageId, boolean finishActivityOnCancel) {
+ this(activity, activity.getResources().getString(messageId), finishActivityOnCancel);
+ }
+
+ public ErrorDialog(final Activity activity, String message, final boolean finishActivityOnClose) {
+
+ AlertDialog.Builder builder = new AlertDialog.Builder(activity);
+ builder.setIcon(android.R.drawable.ic_dialog_alert);
+ builder.setTitle(R.string.error_label);
+ builder.setMessage(message);
+ builder.setCancelable(true);
+ builder.setOnCancelListener(new DialogInterface.OnCancelListener() {
+ @Override
+ public void onCancel(DialogInterface dialogInterface) {
+ if (finishActivityOnClose) {
+ activity.finish();
+ }
+ }
+ });
+ builder.setPositiveButton(R.string.common_ok, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialogInterface, int i) {
+ if (finishActivityOnClose) {
+ activity.finish();
+ }
+ }
+ });
+
+ builder.create().show();
+ }
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/FileUtil.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/FileUtil.java
new file mode 100644
index 00000000..6cdd6fb1
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/FileUtil.java
@@ -0,0 +1,301 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.util;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.io.Serializable;
+import java.util.Arrays;
+import java.util.SortedSet;
+import java.util.TreeSet;
+import java.util.Iterator;
+import java.util.List;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.os.Environment;
+import android.util.Log;
+import net.sourceforge.subsonic.androidapp.domain.MusicDirectory;
+
+/**
+ * @author Sindre Mehus
+ */
+public class FileUtil {
+
+ private static final String TAG = FileUtil.class.getSimpleName();
+ private static final String[] FILE_SYSTEM_UNSAFE = {"/", "\\", "..", ":", "\"", "?", "*", "<", ">"};
+ private static final String[] FILE_SYSTEM_UNSAFE_DIR = {"\\", "..", ":", "\"", "?", "*", "<", ">"};
+ private static final List<String> MUSIC_FILE_EXTENSIONS = Arrays.asList("mp3", "ogg", "aac", "flac", "m4a", "wav", "wma");
+ private static final File DEFAULT_MUSIC_DIR = createDirectory("music");
+
+ public static File getSongFile(Context context, MusicDirectory.Entry song) {
+ File dir = getAlbumDirectory(context, song);
+
+ StringBuilder fileName = new StringBuilder();
+ Integer track = song.getTrack();
+ if (track != null) {
+ if (track < 10) {
+ fileName.append("0");
+ }
+ fileName.append(track).append("-");
+ }
+
+ fileName.append(fileSystemSafe(song.getTitle())).append(".");
+
+ if (song.getTranscodedSuffix() != null) {
+ fileName.append(song.getTranscodedSuffix());
+ } else {
+ fileName.append(song.getSuffix());
+ }
+
+ return new File(dir, fileName.toString());
+ }
+
+ public static File getAlbumArtFile(Context context, MusicDirectory.Entry entry) {
+ File albumDir = getAlbumDirectory(context, entry);
+ return getAlbumArtFile(albumDir);
+ }
+
+ public static File getAlbumArtFile(File albumDir) {
+ File albumArtDir = getAlbumArtDirectory();
+ return new File(albumArtDir, Util.md5Hex(albumDir.getPath()) + ".jpeg");
+ }
+
+ public static Bitmap getAlbumArtBitmap(Context context, MusicDirectory.Entry entry, int size) {
+ File albumArtFile = getAlbumArtFile(context, entry);
+ if (albumArtFile.exists()) {
+ Bitmap bitmap = BitmapFactory.decodeFile(albumArtFile.getPath());
+ return bitmap == null ? null : Bitmap.createScaledBitmap(bitmap, size, size, true);
+ }
+ return null;
+ }
+
+ public static File getAlbumArtDirectory() {
+ File albumArtDir = new File(getSubsonicDirectory(), "artwork");
+ ensureDirectoryExistsAndIsReadWritable(albumArtDir);
+ ensureDirectoryExistsAndIsReadWritable(new File(albumArtDir, ".nomedia"));
+ return albumArtDir;
+ }
+
+ private static File getAlbumDirectory(Context context, MusicDirectory.Entry entry) {
+ File dir;
+ if (entry.getPath() != null) {
+ File f = new File(fileSystemSafeDir(entry.getPath()));
+ dir = new File(getMusicDirectory(context).getPath() + "/" + (entry.isDirectory() ? f.getPath() : f.getParent()));
+ } else {
+ String artist = fileSystemSafe(entry.getArtist());
+ String album = fileSystemSafe(entry.getAlbum());
+ dir = new File(getMusicDirectory(context).getPath() + "/" + artist + "/" + album);
+ }
+ return dir;
+ }
+
+ public static void createDirectoryForParent(File file) {
+ File dir = file.getParentFile();
+ if (!dir.exists()) {
+ if (!dir.mkdirs()) {
+ Log.e(TAG, "Failed to create directory " + dir);
+ }
+ }
+ }
+
+ private static File createDirectory(String name) {
+ File dir = new File(getSubsonicDirectory(), name);
+ if (!dir.exists() && !dir.mkdirs()) {
+ Log.e(TAG, "Failed to create " + name);
+ }
+ return dir;
+ }
+
+ public static File getSubsonicDirectory() {
+ return new File(Environment.getExternalStorageDirectory(), "subsonic");
+ }
+
+ public static File getDefaultMusicDirectory() {
+ return DEFAULT_MUSIC_DIR;
+ }
+
+ public static File getMusicDirectory(Context context) {
+ String path = Util.getPreferences(context).getString(Constants.PREFERENCES_KEY_CACHE_LOCATION, DEFAULT_MUSIC_DIR.getPath());
+ File dir = new File(path);
+ return ensureDirectoryExistsAndIsReadWritable(dir) ? dir : getDefaultMusicDirectory();
+ }
+
+ public static boolean ensureDirectoryExistsAndIsReadWritable(File dir) {
+ if (dir == null) {
+ return false;
+ }
+
+ if (dir.exists()) {
+ if (!dir.isDirectory()) {
+ Log.w(TAG, dir + " exists but is not a directory.");
+ return false;
+ }
+ } else {
+ if (dir.mkdirs()) {
+ Log.i(TAG, "Created directory " + dir);
+ } else {
+ Log.w(TAG, "Failed to create directory " + dir);
+ return false;
+ }
+ }
+
+ if (!dir.canRead()) {
+ Log.w(TAG, "No read permission for directory " + dir);
+ return false;
+ }
+
+ if (!dir.canWrite()) {
+ Log.w(TAG, "No write permission for directory " + dir);
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Makes a given filename safe by replacing special characters like slashes ("/" and "\")
+ * with dashes ("-").
+ *
+ * @param filename The filename in question.
+ * @return The filename with special characters replaced by hyphens.
+ */
+ private static String fileSystemSafe(String filename) {
+ if (filename == null || filename.trim().length() == 0) {
+ return "unnamed";
+ }
+
+ for (String s : FILE_SYSTEM_UNSAFE) {
+ filename = filename.replace(s, "-");
+ }
+ return filename;
+ }
+
+ /**
+ * Makes a given filename safe by replacing special characters like colons (":")
+ * with dashes ("-").
+ *
+ * @param path The path of the directory in question.
+ * @return The the directory name with special characters replaced by hyphens.
+ */
+ private static String fileSystemSafeDir(String path) {
+ if (path == null || path.trim().length() == 0) {
+ return "";
+ }
+
+ for (String s : FILE_SYSTEM_UNSAFE_DIR) {
+ path = path.replace(s, "-");
+ }
+ return path;
+ }
+
+ /**
+ * Similar to {@link File#listFiles()}, but returns a sorted set.
+ * Never returns {@code null}, instead a warning is logged, and an empty set is returned.
+ */
+ public static SortedSet<File> listFiles(File dir) {
+ File[] files = dir.listFiles();
+ if (files == null) {
+ Log.w(TAG, "Failed to list children for " + dir.getPath());
+ return new TreeSet<File>();
+ }
+
+ return new TreeSet<File>(Arrays.asList(files));
+ }
+
+ public static SortedSet<File> listMusicFiles(File dir) {
+ SortedSet<File> files = listFiles(dir);
+ Iterator<File> iterator = files.iterator();
+ while (iterator.hasNext()) {
+ File file = iterator.next();
+ if (!file.isDirectory() && !isMusicFile(file)) {
+ iterator.remove();
+ }
+ }
+ return files;
+ }
+
+ private static boolean isMusicFile(File file) {
+ String extension = getExtension(file.getName());
+ return MUSIC_FILE_EXTENSIONS.contains(extension);
+ }
+
+ /**
+ * Returns the extension (the substring after the last dot) of the given file. The dot
+ * is not included in the returned extension.
+ *
+ * @param name The filename in question.
+ * @return The extension, or an empty string if no extension is found.
+ */
+ public static String getExtension(String name) {
+ int index = name.lastIndexOf('.');
+ return index == -1 ? "" : name.substring(index + 1).toLowerCase();
+ }
+
+ /**
+ * Returns the base name (the substring before the last dot) of the given file. The dot
+ * is not included in the returned basename.
+ *
+ * @param name The filename in question.
+ * @return The base name, or an empty string if no basename is found.
+ */
+ public static String getBaseName(String name) {
+ int index = name.lastIndexOf('.');
+ return index == -1 ? name : name.substring(0, index);
+ }
+
+ public static <T extends Serializable> boolean serialize(Context context, T obj, String fileName) {
+ File file = new File(context.getCacheDir(), fileName);
+ ObjectOutputStream out = null;
+ try {
+ out = new ObjectOutputStream(new FileOutputStream(file));
+ out.writeObject(obj);
+ Log.i(TAG, "Serialized object to " + file);
+ return true;
+ } catch (Throwable x) {
+ Log.w(TAG, "Failed to serialize object to " + file);
+ return false;
+ } finally {
+ Util.close(out);
+ }
+ }
+
+ public static <T extends Serializable> T deserialize(Context context, String fileName) {
+ File file = new File(context.getCacheDir(), fileName);
+ if (!file.exists() || !file.isFile()) {
+ return null;
+ }
+
+ ObjectInputStream in = null;
+ try {
+ in = new ObjectInputStream(new FileInputStream(file));
+ T result = (T) in.readObject();
+ Log.i(TAG, "Deserialized object from " + file);
+ return result;
+ } catch (Throwable x) {
+ Log.w(TAG, "Failed to deserialize object from " + file, x);
+ return null;
+ } finally {
+ Util.close(in);
+ }
+ }
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/HorizontalSlider.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/HorizontalSlider.java
new file mode 100644
index 00000000..6a79a0a0
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/HorizontalSlider.java
@@ -0,0 +1,141 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.util;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Canvas;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+import android.view.View;
+import android.widget.ProgressBar;
+import net.sourceforge.subsonic.androidapp.R;
+
+/**
+ * @author Sindre Mehus
+ * @version $Id$
+ */
+public class HorizontalSlider extends ProgressBar {
+
+ private final Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.slider_knob);
+ private boolean slidingEnabled;
+ private OnSliderChangeListener listener;
+ private static final int PADDING = 2;
+ private boolean sliding;
+ private int sliderPosition;
+ private int startPosition;
+
+ public interface OnSliderChangeListener {
+ void onSliderChanged(View view, int position, boolean inProgress);
+ }
+
+ public HorizontalSlider(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ public HorizontalSlider(Context context, AttributeSet attrs) {
+ super(context, attrs, android.R.attr.progressBarStyleHorizontal);
+ }
+
+ public HorizontalSlider(Context context) {
+ super(context);
+ }
+
+ public void setSlidingEnabled(boolean slidingEnabled) {
+ if (this.slidingEnabled != slidingEnabled) {
+ this.slidingEnabled = slidingEnabled;
+ invalidate();
+ }
+ }
+
+ public boolean isSlidingEnabled() {
+ return slidingEnabled;
+ }
+
+ public void setOnSliderChangeListener(OnSliderChangeListener listener) {
+ this.listener = listener;
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ super.onDraw(canvas);
+
+ int max = getMax();
+ if (!slidingEnabled || max == 0) {
+ return;
+ }
+
+ int paddingLeft = getPaddingLeft();
+ int paddingRight = getPaddingRight();
+ int paddingTop = getPaddingTop();
+ int paddingBottom = getPaddingBottom();
+
+ int w = getWidth() - paddingLeft - paddingRight;
+ int h = getHeight() - paddingTop - paddingBottom;
+ int position = sliding ? sliderPosition : getProgress();
+
+ int bitmapWidth = bitmap.getWidth();
+ int bitmapHeight = bitmap.getWidth();
+ float x = paddingLeft + w * ((float) position / max) - bitmapWidth / 2.0F;
+ x = Math.max(x, paddingLeft);
+ x = Math.min(x, paddingLeft + w - bitmapWidth);
+ float y = paddingTop + h / 2.0F - bitmapHeight / 2.0F;
+
+ canvas.drawBitmap(bitmap, x, y, null);
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ if (!slidingEnabled) {
+ return false;
+ }
+
+ int action = event.getAction();
+
+ if (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_MOVE) {
+
+ if (action == MotionEvent.ACTION_DOWN) {
+ sliding = true;
+ startPosition = getProgress();
+ }
+
+ float x = event.getX() - PADDING;
+ float width = getWidth() - 2 * PADDING;
+ sliderPosition = Math.round((float) getMax() * (x / width));
+ sliderPosition = Math.max(sliderPosition, 0);
+
+ setProgress(Math.min(startPosition, sliderPosition));
+ setSecondaryProgress(Math.max(startPosition, sliderPosition));
+ if (listener != null) {
+ listener.onSliderChanged(this, sliderPosition, true);
+ }
+
+ } else if (action == MotionEvent.ACTION_UP) {
+ sliding = false;
+ setProgress(sliderPosition);
+ setSecondaryProgress(0);
+ if (listener != null) {
+ listener.onSliderChanged(this, sliderPosition, false);
+ }
+ }
+
+ return true;
+ }
+} \ No newline at end of file
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/ImageLoader.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/ImageLoader.java
new file mode 100644
index 00000000..5cbd8c9f
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/ImageLoader.java
@@ -0,0 +1,252 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.util;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.LinearGradient;
+import android.graphics.Matrix;
+import android.graphics.Paint;
+import android.graphics.Shader;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.TransitionDrawable;
+import android.os.Handler;
+import android.util.DisplayMetrics;
+import android.util.Log;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.TextView;
+import net.sourceforge.subsonic.androidapp.R;
+import net.sourceforge.subsonic.androidapp.domain.MusicDirectory;
+import net.sourceforge.subsonic.androidapp.service.MusicService;
+import net.sourceforge.subsonic.androidapp.service.MusicServiceFactory;
+
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.LinkedBlockingQueue;
+
+/**
+ * Asynchronous loading of images, with caching.
+ * <p/>
+ * There should normally be only one instance of this class.
+ *
+ * @author Sindre Mehus
+ */
+public class ImageLoader implements Runnable {
+
+ private static final String TAG = ImageLoader.class.getSimpleName();
+ private static final int CONCURRENCY = 5;
+
+ private final LRUCache<String, Drawable> cache = new LRUCache<String, Drawable>(100);
+ private final BlockingQueue<Task> queue;
+ private final int imageSizeDefault;
+ private final int imageSizeLarge;
+ private Drawable largeUnknownImage;
+
+ public ImageLoader(Context context) {
+ queue = new LinkedBlockingQueue<Task>(500);
+
+ // Determine the density-dependent image sizes.
+ imageSizeDefault = context.getResources().getDrawable(R.drawable.unknown_album).getIntrinsicHeight();
+ DisplayMetrics metrics = context.getResources().getDisplayMetrics();
+ imageSizeLarge = (int) Math.round(Math.min(metrics.widthPixels, metrics.heightPixels) * 0.6);
+
+ for (int i = 0; i < CONCURRENCY; i++) {
+ new Thread(this, "ImageLoader").start();
+ }
+
+ createLargeUnknownImage(context);
+ }
+
+ private void createLargeUnknownImage(Context context) {
+ BitmapDrawable drawable = (BitmapDrawable) context.getResources().getDrawable(R.drawable.unknown_album_large);
+ Bitmap bitmap = Bitmap.createScaledBitmap(drawable.getBitmap(), imageSizeLarge, imageSizeLarge, true);
+ bitmap = createReflection(bitmap);
+ largeUnknownImage = Util.createDrawableFromBitmap(context, bitmap);
+ }
+
+ public void loadImage(View view, MusicDirectory.Entry entry, boolean large, boolean crossfade) {
+ if (entry == null || entry.getCoverArt() == null) {
+ setUnknownImage(view, large);
+ return;
+ }
+
+ int size = large ? imageSizeLarge : imageSizeDefault;
+ Drawable drawable = cache.get(getKey(entry.getCoverArt(), size));
+ if (drawable != null) {
+ setImage(view, drawable, large);
+ return;
+ }
+
+ if (!large) {
+ setUnknownImage(view, large);
+ }
+ queue.offer(new Task(view, entry, size, large, large, crossfade));
+ }
+
+ private String getKey(String coverArtId, int size) {
+ return coverArtId + size;
+ }
+
+ private void setImage(View view, Drawable drawable, boolean crossfade) {
+ if (view instanceof TextView) {
+ // Cross-fading is not implemented for TextView since it's not in use. It would be easy to add it, though.
+ TextView textView = (TextView) view;
+ textView.setCompoundDrawablesWithIntrinsicBounds(drawable, null, null, null);
+ } else if (view instanceof ImageView) {
+ ImageView imageView = (ImageView) view;
+ if (crossfade) {
+
+ Drawable existingDrawable = imageView.getDrawable();
+ if (existingDrawable == null) {
+ Bitmap emptyImage = Bitmap.createBitmap(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888);
+ existingDrawable = new BitmapDrawable(emptyImage);
+ }
+
+ Drawable[] layers = new Drawable[]{existingDrawable, drawable};
+
+ TransitionDrawable transitionDrawable = new TransitionDrawable(layers);
+ imageView.setImageDrawable(transitionDrawable);
+ transitionDrawable.startTransition(250);
+ } else {
+ imageView.setImageDrawable(drawable);
+ }
+ }
+ }
+
+ private void setUnknownImage(View view, boolean large) {
+ if (large) {
+ setImage(view, largeUnknownImage, false);
+ } else {
+ if (view instanceof TextView) {
+ ((TextView) view).setCompoundDrawablesWithIntrinsicBounds(R.drawable.unknown_album, 0, 0, 0);
+ } else if (view instanceof ImageView) {
+ ((ImageView) view).setImageResource(R.drawable.unknown_album);
+ }
+ }
+ }
+
+ public void clear() {
+ queue.clear();
+ }
+
+ @Override
+ public void run() {
+ while (true) {
+ try {
+ Task task = queue.take();
+ task.execute();
+ } catch (Throwable x) {
+ Log.e(TAG, "Unexpected exception in ImageLoader.", x);
+ }
+ }
+ }
+
+ private Bitmap createReflection(Bitmap originalImage) {
+
+ int width = originalImage.getWidth();
+ int height = originalImage.getHeight();
+
+ // The gap we want between the reflection and the original image
+ final int reflectionGap = 4;
+
+ // This will not scale but will flip on the Y axis
+ Matrix matrix = new Matrix();
+ matrix.preScale(1, -1);
+
+ // Create a Bitmap with the flip matix applied to it.
+ // We only want the bottom half of the image
+ Bitmap reflectionImage = Bitmap.createBitmap(originalImage, 0, height / 2, width, height / 2, matrix, false);
+
+ // Create a new bitmap with same width but taller to fit reflection
+ Bitmap bitmapWithReflection = Bitmap.createBitmap(width, (height + height / 2), Bitmap.Config.ARGB_8888);
+
+ // Create a new Canvas with the bitmap that's big enough for
+ // the image plus gap plus reflection
+ Canvas canvas = new Canvas(bitmapWithReflection);
+
+ // Draw in the original image
+ canvas.drawBitmap(originalImage, 0, 0, null);
+
+ // Draw in the gap
+ Paint defaultPaint = new Paint();
+ canvas.drawRect(0, height, width, height + reflectionGap, defaultPaint);
+
+ // Draw in the reflection
+ canvas.drawBitmap(reflectionImage, 0, height + reflectionGap, null);
+
+ // Create a shader that is a linear gradient that covers the reflection
+ Paint paint = new Paint();
+ LinearGradient shader = new LinearGradient(0, originalImage.getHeight(), 0,
+ bitmapWithReflection.getHeight() + reflectionGap, 0x70000000, 0xff000000,
+ Shader.TileMode.CLAMP);
+
+ // Set the paint to use this shader (linear gradient)
+ paint.setShader(shader);
+
+ // Draw a rectangle using the paint with our linear gradient
+ canvas.drawRect(0, height, width, bitmapWithReflection.getHeight() + reflectionGap, paint);
+
+ return bitmapWithReflection;
+ }
+
+ private class Task {
+ private final View view;
+ private final MusicDirectory.Entry entry;
+ private final Handler handler;
+ private final int size;
+ private final boolean reflection;
+ private final boolean saveToFile;
+ private final boolean crossfade;
+
+ public Task(View view, MusicDirectory.Entry entry, int size, boolean reflection, boolean saveToFile, boolean crossfade) {
+ this.view = view;
+ this.entry = entry;
+ this.size = size;
+ this.reflection = reflection;
+ this.saveToFile = saveToFile;
+ this.crossfade = crossfade;
+ handler = new Handler();
+ }
+
+ public void execute() {
+ try {
+ MusicService musicService = MusicServiceFactory.getMusicService(view.getContext());
+ Bitmap bitmap = musicService.getCoverArt(view.getContext(), entry, size, saveToFile, null);
+
+ if (reflection) {
+ bitmap = createReflection(bitmap);
+ }
+
+ final Drawable drawable = Util.createDrawableFromBitmap(view.getContext(), bitmap);
+ cache.put(getKey(entry.getCoverArt(), size), drawable);
+
+ handler.post(new Runnable() {
+ @Override
+ public void run() {
+ setImage(view, drawable, crossfade);
+ }
+ });
+ } catch (Throwable x) {
+ Log.e(TAG, "Failed to download album art.", x);
+ }
+ }
+ }
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/LRUCache.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/LRUCache.java
new file mode 100644
index 00000000..f6145fb7
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/LRUCache.java
@@ -0,0 +1,102 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.util;
+
+import java.lang.ref.SoftReference;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * @author Sindre Mehus
+ */
+public class LRUCache<K,V>{
+
+ private final int capacity;
+ private final Map<K, TimestampedValue> map;
+
+ public LRUCache(int capacity) {
+ map = new HashMap<K, TimestampedValue>(capacity);
+ this.capacity = capacity;
+ }
+
+ public synchronized V get(K key) {
+ TimestampedValue value = map.get(key);
+
+ V result = null;
+ if (value != null) {
+ value.updateTimestamp();
+ result = value.getValue();
+ }
+
+ return result;
+ }
+
+ public synchronized void put(K key, V value) {
+ if (map.size() >= capacity) {
+ removeOldest();
+ }
+ map.put(key, new TimestampedValue(value));
+ }
+
+ public void clear() {
+ map.clear();
+ }
+
+ private void removeOldest() {
+ K oldestKey = null;
+ long oldestTimestamp = Long.MAX_VALUE;
+
+ for (Map.Entry<K, TimestampedValue> entry : map.entrySet()) {
+ K key = entry.getKey();
+ TimestampedValue value = entry.getValue();
+ if (value.getTimestamp() < oldestTimestamp) {
+ oldestTimestamp = value.getTimestamp();
+ oldestKey = key;
+ }
+ }
+
+ if (oldestKey != null) {
+ map.remove(oldestKey);
+ }
+ }
+
+ private final class TimestampedValue {
+
+ private final SoftReference<V> value;
+ private long timestamp;
+
+ public TimestampedValue(V value) {
+ this.value = new SoftReference<V>(value);
+ updateTimestamp();
+ }
+
+ public V getValue() {
+ return value.get();
+ }
+
+ public long getTimestamp() {
+ return timestamp;
+ }
+
+ public void updateTimestamp() {
+ timestamp = System.currentTimeMillis();
+ }
+ }
+
+} \ No newline at end of file
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/MergeAdapter.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/MergeAdapter.java
new file mode 100644
index 00000000..97dbc125
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/MergeAdapter.java
@@ -0,0 +1,290 @@
+/***
+ Copyright (c) 2008-2009 CommonsWare, LLC
+ Portions (c) 2009 Google, Inc.
+
+ 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 net.sourceforge.subsonic.androidapp.util;
+
+import android.database.DataSetObserver;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+import android.widget.ListAdapter;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Arrays;
+
+/**
+ * Adapter that merges multiple child adapters and views
+ * into a single contiguous whole.
+ * <p/>
+ * Adapters used as pieces within MergeAdapter must
+ * have view type IDs monotonically increasing from 0. Ideally,
+ * adapters also have distinct ranges for their row ids, as
+ * returned by getItemId().
+ */
+public class MergeAdapter extends BaseAdapter {
+
+ private final CascadeDataSetObserver observer = new CascadeDataSetObserver();
+ private final ArrayList<ListAdapter> pieces = new ArrayList<ListAdapter>();
+
+ /**
+ * Stock constructor, simply chaining to the superclass.
+ */
+ public MergeAdapter() {
+ super();
+ }
+
+ /**
+ * Adds a new adapter to the roster of things to appear
+ * in the aggregate list.
+ *
+ * @param adapter Source for row views for this section
+ */
+ public void addAdapter(ListAdapter adapter) {
+ pieces.add(adapter);
+ adapter.registerDataSetObserver(observer);
+ }
+
+ public void removeAdapter(ListAdapter adapter) {
+ adapter.unregisterDataSetObserver(observer);
+ pieces.remove(adapter);
+ }
+
+ /**
+ * Adds a new View to the roster of things to appear
+ * in the aggregate list.
+ *
+ * @param view Single view to add
+ */
+ public ListAdapter addView(View view) {
+ return addView(view, false);
+ }
+
+ /**
+ * Adds a new View to the roster of things to appear
+ * in the aggregate list.
+ *
+ * @param view Single view to add
+ * @param enabled false if views are disabled, true if enabled
+ */
+ public ListAdapter addView(View view, boolean enabled) {
+ return addViews(Arrays.asList(view), enabled);
+ }
+
+ /**
+ * Adds a list of views to the roster of things to appear
+ * in the aggregate list.
+ *
+ * @param views List of views to add
+ */
+ public ListAdapter addViews(List<View> views) {
+ return addViews(views, false);
+ }
+
+ /**
+ * Adds a list of views to the roster of things to appear
+ * in the aggregate list.
+ *
+ * @param views List of views to add
+ * @param enabled false if views are disabled, true if enabled
+ */
+ public ListAdapter addViews(List<View> views, boolean enabled) {
+ ListAdapter adapter = enabled ? new EnabledSackAdapter(views) : new SackOfViewsAdapter(views);
+ addAdapter(adapter);
+ return adapter;
+ }
+
+ /**
+ * Get the data item associated with the specified
+ * position in the data set.
+ *
+ * @param position Position of the item whose data we want
+ */
+ @Override
+ public Object getItem(int position) {
+ for (ListAdapter piece : pieces) {
+ int size = piece.getCount();
+
+ if (position < size) {
+ return (piece.getItem(position));
+ }
+
+ position -= size;
+ }
+
+ return (null);
+ }
+
+ /**
+ * How many items are in the data set represented by this
+ * Adapter.
+ */
+ @Override
+ public int getCount() {
+ int total = 0;
+
+ for (ListAdapter piece : pieces) {
+ total += piece.getCount();
+ }
+
+ return (total);
+ }
+
+ /**
+ * Returns the number of types of Views that will be
+ * created by getView().
+ */
+ @Override
+ public int getViewTypeCount() {
+ int total = 0;
+
+ for (ListAdapter piece : pieces) {
+ total += piece.getViewTypeCount();
+ }
+
+ return (Math.max(total, 1)); // needed for setListAdapter() before content add'
+ }
+
+ /**
+ * Get the type of View that will be created by getView()
+ * for the specified item.
+ *
+ * @param position Position of the item whose data we want
+ */
+ @Override
+ public int getItemViewType(int position) {
+ int typeOffset = 0;
+ int result = -1;
+
+ for (ListAdapter piece : pieces) {
+ int size = piece.getCount();
+
+ if (position < size) {
+ result = typeOffset + piece.getItemViewType(position);
+ break;
+ }
+
+ position -= size;
+ typeOffset += piece.getViewTypeCount();
+ }
+
+ return (result);
+ }
+
+ /**
+ * Are all items in this ListAdapter enabled? If yes it
+ * means all items are selectable and clickable.
+ */
+ @Override
+ public boolean areAllItemsEnabled() {
+ return (false);
+ }
+
+ /**
+ * Returns true if the item at the specified position is
+ * not a separator.
+ *
+ * @param position Position of the item whose data we want
+ */
+ @Override
+ public boolean isEnabled(int position) {
+ for (ListAdapter piece : pieces) {
+ int size = piece.getCount();
+
+ if (position < size) {
+ return (piece.isEnabled(position));
+ }
+
+ position -= size;
+ }
+
+ return (false);
+ }
+
+ /**
+ * Get a View that displays the data at the specified
+ * position in the data set.
+ *
+ * @param position Position of the item whose data we want
+ * @param convertView View to recycle, if not null
+ * @param parent ViewGroup containing the returned View
+ */
+ @Override
+ public View getView(int position, View convertView,
+ ViewGroup parent) {
+ for (ListAdapter piece : pieces) {
+ int size = piece.getCount();
+
+ if (position < size) {
+
+ return (piece.getView(position, convertView, parent));
+ }
+
+ position -= size;
+ }
+
+ return (null);
+ }
+
+ /**
+ * Get the row id associated with the specified position
+ * in the list.
+ *
+ * @param position Position of the item whose data we want
+ */
+ @Override
+ public long getItemId(int position) {
+ for (ListAdapter piece : pieces) {
+ int size = piece.getCount();
+
+ if (position < size) {
+ return (piece.getItemId(position));
+ }
+
+ position -= size;
+ }
+
+ return (-1);
+ }
+
+ private static class EnabledSackAdapter extends SackOfViewsAdapter {
+ public EnabledSackAdapter(List<View> views) {
+ super(views);
+ }
+
+ @Override
+ public boolean areAllItemsEnabled() {
+ return (true);
+ }
+
+ @Override
+ public boolean isEnabled(int position) {
+ return (true);
+ }
+ }
+
+ private class CascadeDataSetObserver extends DataSetObserver {
+ @Override
+ public void onChanged() {
+ notifyDataSetChanged();
+ }
+
+ @Override
+ public void onInvalidated() {
+ notifyDataSetInvalidated();
+ }
+ }
+}
+
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/ModalBackgroundTask.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/ModalBackgroundTask.java
new file mode 100644
index 00000000..15e2add2
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/ModalBackgroundTask.java
@@ -0,0 +1,139 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.util;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.content.DialogInterface;
+import android.util.Log;
+import net.sourceforge.subsonic.androidapp.R;
+
+/**
+ * @author Sindre Mehus
+ */
+public abstract class ModalBackgroundTask<T> extends BackgroundTask<T> {
+
+ private static final String TAG = ModalBackgroundTask.class.getSimpleName();
+
+ private final AlertDialog progressDialog;
+ private Thread thread;
+ private final boolean finishActivityOnCancel;
+ private boolean cancelled;
+
+ public ModalBackgroundTask(Activity activity, boolean finishActivityOnCancel) {
+ super(activity);
+ this.finishActivityOnCancel = finishActivityOnCancel;
+ progressDialog = createProgressDialog();
+ }
+
+ public ModalBackgroundTask(Activity activity) {
+ this(activity, true);
+ }
+
+ private AlertDialog createProgressDialog() {
+ AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
+ builder.setIcon(android.R.drawable.ic_dialog_info);
+ builder.setTitle(R.string.background_task_wait);
+ builder.setMessage(R.string.background_task_loading);
+ builder.setCancelable(true);
+ builder.setOnCancelListener(new DialogInterface.OnCancelListener() {
+ @Override
+ public void onCancel(DialogInterface dialogInterface) {
+ cancel();
+ }
+ });
+ builder.setPositiveButton(R.string.common_cancel, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialogInterface, int i) {
+ cancel();
+ }
+ });
+
+ return builder.create();
+ }
+
+ public void execute() {
+ cancelled = false;
+ progressDialog.show();
+
+ thread = new Thread() {
+ @Override
+ public void run() {
+ try {
+ final T result = doInBackground();
+ if (cancelled) {
+ progressDialog.dismiss();
+ return;
+ }
+
+ getHandler().post(new Runnable() {
+ @Override
+ public void run() {
+ progressDialog.dismiss();
+ done(result);
+ }
+ });
+
+ } catch (final Throwable t) {
+ if (cancelled) {
+ return;
+ }
+ getHandler().post(new Runnable() {
+ @Override
+ public void run() {
+ progressDialog.dismiss();
+ error(t);
+ }
+ });
+ }
+ }
+ };
+ thread.start();
+ }
+
+ protected void cancel() {
+ cancelled = true;
+ if (thread != null) {
+ thread.interrupt();
+ }
+
+ if (finishActivityOnCancel) {
+ getActivity().finish();
+ }
+ }
+
+ protected boolean isCancelled() {
+ return cancelled;
+ }
+
+ protected void error(Throwable error) {
+ Log.w(TAG, "Got exception: " + error, error);
+ new ErrorDialog(getActivity(), getErrorMessage(error), finishActivityOnCancel);
+ }
+
+ @Override
+ public void updateProgress(final String message) {
+ getHandler().post(new Runnable() {
+ @Override
+ public void run() {
+ progressDialog.setMessage(message);
+ }
+ });
+ }
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/MyViewFlipper.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/MyViewFlipper.java
new file mode 100644
index 00000000..94f217ff
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/MyViewFlipper.java
@@ -0,0 +1,53 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.util;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.widget.ViewFlipper;
+
+/**
+ * Work-around for Android Issue 6191 (http://code.google.com/p/android/issues/detail?id=6191)
+ *
+ * @author Sindre Mehus
+ * @version $Id$
+ */
+public class MyViewFlipper extends ViewFlipper {
+
+ public MyViewFlipper(Context context) {
+ super(context);
+ }
+
+ public MyViewFlipper(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+
+ @Override
+ protected void onDetachedFromWindow() {
+ try {
+ super.onDetachedFromWindow();
+ }
+ catch (IllegalArgumentException e) {
+ // Call stopFlipping() in order to kick off updateRunning()
+ stopFlipping();
+ }
+ }
+}
+
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/Pair.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/Pair.java
new file mode 100644
index 00000000..73dc3224
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/Pair.java
@@ -0,0 +1,54 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.util;
+
+import java.io.Serializable;
+
+/**
+ * @author Sindre Mehus
+ */
+public class Pair<S, T> implements Serializable {
+
+ private S first;
+ private T second;
+
+ public Pair() {
+ }
+
+ public Pair(S first, T second) {
+ this.first = first;
+ this.second = second;
+ }
+
+ public S getFirst() {
+ return first;
+ }
+
+ public void setFirst(S first) {
+ this.first = first;
+ }
+
+ public T getSecond() {
+ return second;
+ }
+
+ public void setSecond(T second) {
+ this.second = second;
+ }
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/PlaylistAdapter.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/PlaylistAdapter.java
new file mode 100644
index 00000000..16028c12
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/PlaylistAdapter.java
@@ -0,0 +1,99 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.util;
+
+import android.content.Context;
+import android.widget.ArrayAdapter;
+import android.widget.SectionIndexer;
+import net.sourceforge.subsonic.androidapp.R;
+import net.sourceforge.subsonic.androidapp.domain.Playlist;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+* @author Sindre Mehus
+* @version $Id$
+*/
+public class PlaylistAdapter extends ArrayAdapter<Playlist> implements SectionIndexer {
+
+ // Both arrays are indexed by section ID.
+ private final Object[] sections;
+ private final Integer[] positions;
+
+ /**
+ * Note: playlists must be sorted alphabetically.
+ */
+ public PlaylistAdapter(Context context, List<Playlist> playlists) {
+ super(context, R.layout.playlist_list_item, playlists);
+
+ Set<String> sectionSet = new LinkedHashSet<String>(30);
+ List<Integer> positionList = new ArrayList<Integer>(30);
+ for (int i = 0; i < playlists.size(); i++) {
+ Playlist playlist = playlists.get(i);
+ if (playlist.getName().length() > 0) {
+ String index = playlist.getName().substring(0, 1).toUpperCase();
+ if (!sectionSet.contains(index)) {
+ sectionSet.add(index);
+ positionList.add(i);
+ }
+ }
+ }
+ sections = sectionSet.toArray(new Object[sectionSet.size()]);
+ positions = positionList.toArray(new Integer[positionList.size()]);
+ }
+
+ @Override
+ public Object[] getSections() {
+ return sections;
+ }
+
+ @Override
+ public int getPositionForSection(int section) {
+ section = Math.min(section, positions.length - 1);
+ return positions[section];
+ }
+
+ @Override
+ public int getSectionForPosition(int pos) {
+ for (int i = 0; i < sections.length - 1; i++) {
+ if (pos < positions[i + 1]) {
+ return i;
+ }
+ }
+ return sections.length - 1;
+ }
+
+ public static class PlaylistComparator implements Comparator<Playlist> {
+ @Override
+ public int compare(Playlist playlist1, Playlist playlist2) {
+ return playlist1.getName().compareToIgnoreCase(playlist2.getName());
+ }
+
+ public static List<Playlist> sort(List<Playlist> playlists) {
+ Collections.sort(playlists, new PlaylistComparator());
+ return playlists;
+ }
+
+ }
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/ProgressListener.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/ProgressListener.java
new file mode 100644
index 00000000..0d2924f7
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/ProgressListener.java
@@ -0,0 +1,27 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.util;
+
+/**
+ * @author Sindre Mehus
+ */
+public interface ProgressListener {
+ void updateProgress(String message);
+ void updateProgress(int messageId);
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/SackOfViewsAdapter.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/SackOfViewsAdapter.java
new file mode 100644
index 00000000..ca825e55
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/SackOfViewsAdapter.java
@@ -0,0 +1,181 @@
+/***
+ Copyright (c) 2008-2009 CommonsWare, LLC
+ Portions (c) 2009 Google, Inc.
+
+ 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 net.sourceforge.subsonic.androidapp.util;
+
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+import android.widget.ListView;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Adapter that simply returns row views from a list.
+ * <p/>
+ * If you supply a size, you must implement newView(), to
+ * create a required view. The adapter will then cache these
+ * views.
+ * <p/>
+ * If you supply a list of views in the constructor, that
+ * list will be used directly. If any elements in the list
+ * are null, then newView() will be called just for those
+ * slots.
+ * <p/>
+ * Subclasses may also wish to override areAllItemsEnabled()
+ * (default: false) and isEnabled() (default: false), if some
+ * of their rows should be selectable.
+ * <p/>
+ * It is assumed each view is unique, and therefore will not
+ * get recycled.
+ * <p/>
+ * Note that this adapter is not designed for long lists. It
+ * is more for screens that should behave like a list. This
+ * is particularly useful if you combine this with other
+ * adapters (e.g., SectionedAdapter) that might have an
+ * arbitrary number of rows, so it all appears seamless.
+ */
+public class SackOfViewsAdapter extends BaseAdapter {
+ private List<View> views = null;
+
+ /**
+ * Constructor creating an empty list of views, but with
+ * a specified count. Subclasses must override newView().
+ */
+ public SackOfViewsAdapter(int count) {
+ super();
+
+ views = new ArrayList<View>(count);
+
+ for (int i = 0; i < count; i++) {
+ views.add(null);
+ }
+ }
+
+ /**
+ * Constructor wrapping a supplied list of views.
+ * Subclasses must override newView() if any of the elements
+ * in the list are null.
+ */
+ public SackOfViewsAdapter(List<View> views) {
+ for (View view : views) {
+ view.setLayoutParams(new ListView.LayoutParams(ViewGroup.LayoutParams.FILL_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
+ }
+ this.views = views;
+ }
+
+ /**
+ * Get the data item associated with the specified
+ * position in the data set.
+ *
+ * @param position Position of the item whose data we want
+ */
+ @Override
+ public Object getItem(int position) {
+ return (views.get(position));
+ }
+
+ /**
+ * How many items are in the data set represented by this
+ * Adapter.
+ */
+ @Override
+ public int getCount() {
+ return (views.size());
+ }
+
+ /**
+ * Returns the number of types of Views that will be
+ * created by getView().
+ */
+ @Override
+ public int getViewTypeCount() {
+ return (getCount());
+ }
+
+ /**
+ * Get the type of View that will be created by getView()
+ * for the specified item.
+ *
+ * @param position Position of the item whose data we want
+ */
+ @Override
+ public int getItemViewType(int position) {
+ return (position);
+ }
+
+ /**
+ * Are all items in this ListAdapter enabled? If yes it
+ * means all items are selectable and clickable.
+ */
+ @Override
+ public boolean areAllItemsEnabled() {
+ return (false);
+ }
+
+ /**
+ * Returns true if the item at the specified position is
+ * not a separator.
+ *
+ * @param position Position of the item whose data we want
+ */
+ @Override
+ public boolean isEnabled(int position) {
+ return (false);
+ }
+
+ /**
+ * Get a View that displays the data at the specified
+ * position in the data set.
+ *
+ * @param position Position of the item whose data we want
+ * @param convertView View to recycle, if not null
+ * @param parent ViewGroup containing the returned View
+ */
+ @Override
+ public View getView(int position, View convertView,
+ ViewGroup parent) {
+ View result = views.get(position);
+
+ if (result == null) {
+ result = newView(position, parent);
+ views.set(position, result);
+ }
+
+ return (result);
+ }
+
+ /**
+ * Get the row id associated with the specified position
+ * in the list.
+ *
+ * @param position Position of the item whose data we want
+ */
+ @Override
+ public long getItemId(int position) {
+ return (position);
+ }
+
+ /**
+ * Create a new View to go into the list at the specified
+ * position.
+ *
+ * @param position Position of the item whose data we want
+ * @param parent ViewGroup containing the returned View
+ */
+ protected View newView(int position, ViewGroup parent) {
+ throw new RuntimeException("You must override newView()!");
+ }
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/ShufflePlayBuffer.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/ShufflePlayBuffer.java
new file mode 100644
index 00000000..825fcc44
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/ShufflePlayBuffer.java
@@ -0,0 +1,109 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.util;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+import android.content.Context;
+import android.util.Log;
+import net.sourceforge.subsonic.androidapp.domain.MusicDirectory;
+import net.sourceforge.subsonic.androidapp.service.MusicService;
+import net.sourceforge.subsonic.androidapp.service.MusicServiceFactory;
+
+/**
+ * @author Sindre Mehus
+ * @version $Id$
+ */
+public class ShufflePlayBuffer {
+
+ private static final String TAG = ShufflePlayBuffer.class.getSimpleName();
+ private static final int CAPACITY = 50;
+ private static final int REFILL_THRESHOLD = 40;
+
+ private final ScheduledExecutorService executorService;
+ private final List<MusicDirectory.Entry> buffer = new ArrayList<MusicDirectory.Entry>();
+ private Context context;
+ private int currentServer;
+
+ public ShufflePlayBuffer(Context context) {
+ this.context = context;
+ executorService = Executors.newSingleThreadScheduledExecutor();
+ Runnable runnable = new Runnable() {
+ @Override
+ public void run() {
+ refill();
+ }
+ };
+ executorService.scheduleWithFixedDelay(runnable, 1, 10, TimeUnit.SECONDS);
+ }
+
+ public List<MusicDirectory.Entry> get(int size) {
+ clearBufferIfnecessary();
+
+ List<MusicDirectory.Entry> result = new ArrayList<MusicDirectory.Entry>(size);
+ synchronized (buffer) {
+ while (!buffer.isEmpty() && result.size() < size) {
+ result.add(buffer.remove(buffer.size() - 1));
+ }
+ }
+ Log.i(TAG, "Taking " + result.size() + " songs from shuffle play buffer. " + buffer.size() + " remaining.");
+ return result;
+ }
+
+ public void shutdown() {
+ executorService.shutdown();
+ }
+
+ private void refill() {
+
+ // Check if active server has changed.
+ clearBufferIfnecessary();
+
+ if (buffer.size() > REFILL_THRESHOLD || (!Util.isNetworkConnected(context) && !Util.isOffline(context))) {
+ return;
+ }
+
+ try {
+ MusicService service = MusicServiceFactory.getMusicService(context);
+ int n = CAPACITY - buffer.size();
+ MusicDirectory songs = service.getRandomSongs(n, context, null);
+
+ synchronized (buffer) {
+ buffer.addAll(songs.getChildren());
+ Log.i(TAG, "Refilled shuffle play buffer with " + songs.getChildren().size() + " songs.");
+ }
+ } catch (Exception x) {
+ Log.w(TAG, "Failed to refill shuffle play buffer.", x);
+ }
+ }
+
+ private void clearBufferIfnecessary() {
+ synchronized (buffer) {
+ if (currentServer != Util.getActiveServer(context)) {
+ currentServer = Util.getActiveServer(context);
+ buffer.clear();
+ }
+ }
+ }
+
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/SilentBackgroundTask.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/SilentBackgroundTask.java
new file mode 100644
index 00000000..7aa85d7c
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/SilentBackgroundTask.java
@@ -0,0 +1,67 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2010 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.util;
+
+import android.app.Activity;
+
+/**
+ * @author Sindre Mehus
+ */
+public abstract class SilentBackgroundTask<T> extends BackgroundTask<T> {
+
+ public SilentBackgroundTask(Activity activity) {
+ super(activity);
+ }
+
+ @Override
+ public void execute() {
+ Thread thread = new Thread() {
+ @Override
+ public void run() {
+ try {
+ final T result = doInBackground();
+
+ getHandler().post(new Runnable() {
+ @Override
+ public void run() {
+ done(result);
+ }
+ });
+
+ } catch (final Throwable t) {
+ getHandler().post(new Runnable() {
+ @Override
+ public void run() {
+ error(t);
+ }
+ });
+ }
+ }
+ };
+ thread.start();
+ }
+
+ @Override
+ public void updateProgress(int messageId) {
+ }
+
+ @Override
+ public void updateProgress(String message) {
+ }
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/SimpleServiceBinder.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/SimpleServiceBinder.java
new file mode 100644
index 00000000..9ddf9903
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/SimpleServiceBinder.java
@@ -0,0 +1,37 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.util;
+
+import android.os.Binder;
+
+/**
+ * @author Sindre Mehus
+ */
+public class SimpleServiceBinder<S> extends Binder {
+
+ private final S service;
+
+ public SimpleServiceBinder(S service) {
+ this.service = service;
+ }
+
+ public S getService() {
+ return service;
+ }
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/SongView.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/SongView.java
new file mode 100644
index 00000000..22902a11
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/SongView.java
@@ -0,0 +1,178 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.util;
+
+import android.content.Context;
+import android.os.Handler;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.Checkable;
+import android.widget.CheckedTextView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+import net.sourceforge.subsonic.androidapp.R;
+import net.sourceforge.subsonic.androidapp.domain.MusicDirectory;
+import net.sourceforge.subsonic.androidapp.service.DownloadService;
+import net.sourceforge.subsonic.androidapp.service.DownloadServiceImpl;
+import net.sourceforge.subsonic.androidapp.service.DownloadFile;
+
+import java.io.File;
+import java.util.WeakHashMap;
+
+/**
+ * Used to display songs in a {@code ListView}.
+ *
+ * @author Sindre Mehus
+ */
+public class SongView extends LinearLayout implements Checkable {
+
+ private static final String TAG = SongView.class.getSimpleName();
+ private static final WeakHashMap<SongView, ?> INSTANCES = new WeakHashMap<SongView, Object>();
+ private static Handler handler;
+
+ private CheckedTextView checkedTextView;
+ private TextView titleTextView;
+ private TextView artistTextView;
+ private TextView durationTextView;
+ private TextView statusTextView;
+ private MusicDirectory.Entry song;
+
+ public SongView(Context context) {
+ super(context);
+ LayoutInflater.from(context).inflate(R.layout.song_list_item, this, true);
+
+ checkedTextView = (CheckedTextView) findViewById(R.id.song_check);
+ titleTextView = (TextView) findViewById(R.id.song_title);
+ artistTextView = (TextView) findViewById(R.id.song_artist);
+ durationTextView = (TextView) findViewById(R.id.song_duration);
+ statusTextView = (TextView) findViewById(R.id.song_status);
+
+ INSTANCES.put(this, null);
+ int instanceCount = INSTANCES.size();
+ if (instanceCount > 50) {
+ Log.w(TAG, instanceCount + " live SongView instances");
+ }
+ startUpdater();
+ }
+
+ public void setSong(MusicDirectory.Entry song, boolean checkable) {
+ this.song = song;
+ StringBuilder artist = new StringBuilder(40);
+
+ String bitRate = null;
+ if (song.getBitRate() != null) {
+ bitRate = String.format(getContext().getString(R.string.song_details_kbps), song.getBitRate());
+ }
+
+ String fileFormat = null;
+ if (song.getTranscodedSuffix() != null && !song.getTranscodedSuffix().equals(song.getSuffix())) {
+ fileFormat = String.format("%s > %s", song.getSuffix(), song.getTranscodedSuffix());
+ } else {
+ fileFormat = song.getSuffix();
+ }
+
+ artist.append(song.getArtist()).append(" (")
+ .append(String.format(getContext().getString(R.string.song_details_all), bitRate == null ? "" : bitRate, fileFormat))
+ .append(")");
+
+ titleTextView.setText(song.getTitle());
+ artistTextView.setText(artist);
+ durationTextView.setText(Util.formatDuration(song.getDuration()));
+ checkedTextView.setVisibility(checkable && !song.isVideo() ? View.VISIBLE : View.GONE);
+
+ update();
+ }
+
+ private void update() {
+ DownloadService downloadService = DownloadServiceImpl.getInstance();
+ if (downloadService == null) {
+ return;
+ }
+
+ DownloadFile downloadFile = downloadService.forSong(song);
+ File completeFile = downloadFile.getCompleteFile();
+ File partialFile = downloadFile.getPartialFile();
+
+ int leftImage = 0;
+ int rightImage = 0;
+
+ if (completeFile.exists()) {
+ leftImage = downloadFile.isSaved() ? R.drawable.saved : R.drawable.downloaded;
+ }
+
+ if (downloadFile.isDownloading() && !downloadFile.isDownloadCancelled() && partialFile.exists()) {
+ statusTextView.setText(Util.formatLocalizedBytes(partialFile.length(), getContext()));
+ rightImage = R.drawable.downloading;
+ } else {
+ statusTextView.setText(null);
+ }
+ statusTextView.setCompoundDrawablesWithIntrinsicBounds(leftImage, 0, rightImage, 0);
+
+ boolean playing = downloadService.getCurrentPlaying() == downloadFile;
+ if (playing) {
+ titleTextView.setCompoundDrawablesWithIntrinsicBounds(R.drawable.stat_notify_playing, 0, 0, 0);
+ } else {
+ titleTextView.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0);
+ }
+ }
+
+ private static synchronized void startUpdater() {
+ if (handler != null) {
+ return;
+ }
+
+ handler = new Handler();
+ Runnable runnable = new Runnable() {
+ @Override
+ public void run() {
+ updateAll();
+ handler.postDelayed(this, 1000L);
+ }
+ };
+ handler.postDelayed(runnable, 1000L);
+ }
+
+ private static void updateAll() {
+ try {
+ for (SongView view : INSTANCES.keySet()) {
+ if (view.isShown()) {
+ view.update();
+ }
+ }
+ } catch (Throwable x) {
+ Log.w(TAG, "Error when updating song views.", x);
+ }
+ }
+
+ @Override
+ public void setChecked(boolean b) {
+ checkedTextView.setChecked(b);
+ }
+
+ @Override
+ public boolean isChecked() {
+ return checkedTextView.isChecked();
+ }
+
+ @Override
+ public void toggle() {
+ checkedTextView.toggle();
+ }
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/TabActivityBackgroundTask.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/TabActivityBackgroundTask.java
new file mode 100644
index 00000000..033a51ad
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/TabActivityBackgroundTask.java
@@ -0,0 +1,67 @@
+package net.sourceforge.subsonic.androidapp.util;
+
+import net.sourceforge.subsonic.androidapp.activity.SubsonicTabActivity;
+
+/**
+ * @author Sindre Mehus
+ * @version $Id$
+ */
+public abstract class TabActivityBackgroundTask<T> extends BackgroundTask<T> {
+
+ private final SubsonicTabActivity tabActivity;
+
+ public TabActivityBackgroundTask(SubsonicTabActivity activity) {
+ super(activity);
+ tabActivity = activity;
+ }
+
+ @Override
+ public void execute() {
+ tabActivity.setProgressVisible(true);
+
+ new Thread() {
+ @Override
+ public void run() {
+ try {
+ final T result = doInBackground();
+ if (isCancelled()) {
+ return;
+ }
+
+ getHandler().post(new Runnable() {
+ @Override
+ public void run() {
+ tabActivity.setProgressVisible(false);
+ done(result);
+ }
+ });
+ } catch (final Throwable t) {
+ if (isCancelled()) {
+ return;
+ }
+ getHandler().post(new Runnable() {
+ @Override
+ public void run() {
+ tabActivity.setProgressVisible(false);
+ error(t);
+ }
+ });
+ }
+ }
+ }.start();
+ }
+
+ private boolean isCancelled() {
+ return tabActivity.isDestroyed();
+ }
+
+ @Override
+ public void updateProgress(final String message) {
+ getHandler().post(new Runnable() {
+ @Override
+ public void run() {
+ tabActivity.updateProgress(message);
+ }
+ });
+ }
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/TimeLimitedCache.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/TimeLimitedCache.java
new file mode 100644
index 00000000..5df5901e
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/TimeLimitedCache.java
@@ -0,0 +1,55 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.util;
+
+import java.lang.ref.SoftReference;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * @author Sindre Mehus
+ * @version $Id$
+ */
+public class TimeLimitedCache<T> {
+
+ private SoftReference<T> value;
+ private final long ttlMillis;
+ private long expires;
+
+ public TimeLimitedCache(long ttl, TimeUnit timeUnit) {
+ this.ttlMillis = TimeUnit.MILLISECONDS.convert(ttl, timeUnit);
+ }
+
+ public T get() {
+ return System.currentTimeMillis() < expires ? value.get() : null;
+ }
+
+ public void set(T value) {
+ set(value, ttlMillis, TimeUnit.MILLISECONDS);
+ }
+
+ public void set(T value, long ttl, TimeUnit timeUnit) {
+ this.value = new SoftReference<T>(value);
+ expires = System.currentTimeMillis() + timeUnit.toMillis(ttl);
+ }
+
+ public void clear() {
+ expires = 0L;
+ value = null;
+ }
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/Util.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/Util.java
new file mode 100644
index 00000000..9a8c692d
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/Util.java
@@ -0,0 +1,829 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.util;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.app.Service;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import android.media.AudioManager;
+import android.net.ConnectivityManager;
+import android.net.NetworkInfo;
+import android.os.Environment;
+import android.os.Handler;
+import android.util.Log;
+import android.view.Gravity;
+import android.view.ViewGroup;
+import android.widget.LinearLayout;
+import android.widget.RemoteViews;
+import android.widget.TextView;
+import android.widget.Toast;
+import net.sourceforge.subsonic.androidapp.R;
+import net.sourceforge.subsonic.androidapp.activity.DownloadActivity;
+import net.sourceforge.subsonic.androidapp.domain.MusicDirectory;
+import net.sourceforge.subsonic.androidapp.domain.PlayerState;
+import net.sourceforge.subsonic.androidapp.domain.RepeatMode;
+import net.sourceforge.subsonic.androidapp.domain.Version;
+import net.sourceforge.subsonic.androidapp.provider.SubsonicAppWidgetProvider;
+import net.sourceforge.subsonic.androidapp.receiver.MediaButtonIntentReceiver;
+import net.sourceforge.subsonic.androidapp.service.DownloadServiceImpl;
+import org.apache.http.HttpEntity;
+
+import java.io.ByteArrayOutputStream;
+import java.io.Closeable;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.UnsupportedEncodingException;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Method;
+import java.security.MessageDigest;
+import java.text.DecimalFormat;
+import java.text.NumberFormat;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * @author Sindre Mehus
+ * @version $Id$
+ */
+public final class Util {
+
+ private static final String TAG = Util.class.getSimpleName();
+
+ private static final DecimalFormat GIGA_BYTE_FORMAT = new DecimalFormat("0.00 GB");
+ private static final DecimalFormat MEGA_BYTE_FORMAT = new DecimalFormat("0.00 MB");
+ private static final DecimalFormat KILO_BYTE_FORMAT = new DecimalFormat("0 KB");
+
+ private static DecimalFormat GIGA_BYTE_LOCALIZED_FORMAT = null;
+ private static DecimalFormat MEGA_BYTE_LOCALIZED_FORMAT = null;
+ private static DecimalFormat KILO_BYTE_LOCALIZED_FORMAT = null;
+ private static DecimalFormat BYTE_LOCALIZED_FORMAT = null;
+
+ public static final String EVENT_META_CHANGED = "net.sourceforge.subsonic.androidapp.EVENT_META_CHANGED";
+ public static final String EVENT_PLAYSTATE_CHANGED = "net.sourceforge.subsonic.androidapp.EVENT_PLAYSTATE_CHANGED";
+
+ private static final Map<Integer, Version> SERVER_REST_VERSIONS = new ConcurrentHashMap<Integer, Version>();
+
+ // Used by hexEncode()
+ private static final char[] HEX_DIGITS = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'};
+
+ private final static Pair<Integer, Integer> NOTIFICATION_TEXT_COLORS = new Pair<Integer, Integer>();
+ private static Toast toast;
+
+ private Util() {
+ }
+
+ public static boolean isOffline(Context context) {
+ return getActiveServer(context) == 0;
+ }
+
+ public static boolean isScreenLitOnDownload(Context context) {
+ SharedPreferences prefs = getPreferences(context);
+ return prefs.getBoolean(Constants.PREFERENCES_KEY_SCREEN_LIT_ON_DOWNLOAD, false);
+ }
+
+ public static RepeatMode getRepeatMode(Context context) {
+ SharedPreferences prefs = getPreferences(context);
+ return RepeatMode.valueOf(prefs.getString(Constants.PREFERENCES_KEY_REPEAT_MODE, RepeatMode.OFF.name()));
+ }
+
+ public static void setRepeatMode(Context context, RepeatMode repeatMode) {
+ SharedPreferences prefs = getPreferences(context);
+ SharedPreferences.Editor editor = prefs.edit();
+ editor.putString(Constants.PREFERENCES_KEY_REPEAT_MODE, repeatMode.name());
+ editor.commit();
+ }
+
+ public static boolean isScrobblingEnabled(Context context) {
+ if (isOffline(context)) {
+ return false;
+ }
+ SharedPreferences prefs = getPreferences(context);
+ return prefs.getBoolean(Constants.PREFERENCES_KEY_SCROBBLE, false);
+ }
+
+ public static void setActiveServer(Context context, int instance) {
+ SharedPreferences prefs = getPreferences(context);
+ SharedPreferences.Editor editor = prefs.edit();
+ editor.putInt(Constants.PREFERENCES_KEY_SERVER_INSTANCE, instance);
+ editor.commit();
+ }
+
+ public static int getActiveServer(Context context) {
+ SharedPreferences prefs = getPreferences(context);
+ return prefs.getInt(Constants.PREFERENCES_KEY_SERVER_INSTANCE, 1);
+ }
+
+ public static String getServerName(Context context, int instance) {
+ if (instance == 0) {
+ return context.getResources().getString(R.string.main_offline);
+ }
+ SharedPreferences prefs = getPreferences(context);
+ return prefs.getString(Constants.PREFERENCES_KEY_SERVER_NAME + instance, null);
+ }
+
+ public static void setServerRestVersion(Context context, Version version) {
+ SERVER_REST_VERSIONS.put(getActiveServer(context), version);
+ }
+
+ public static Version getServerRestVersion(Context context) {
+ return SERVER_REST_VERSIONS.get(getActiveServer(context));
+ }
+
+ public static void setSelectedMusicFolderId(Context context, String musicFolderId) {
+ int instance = getActiveServer(context);
+ SharedPreferences prefs = getPreferences(context);
+ SharedPreferences.Editor editor = prefs.edit();
+ editor.putString(Constants.PREFERENCES_KEY_MUSIC_FOLDER_ID + instance, musicFolderId);
+ editor.commit();
+ }
+
+ public static String getSelectedMusicFolderId(Context context) {
+ SharedPreferences prefs = getPreferences(context);
+ int instance = getActiveServer(context);
+ return prefs.getString(Constants.PREFERENCES_KEY_MUSIC_FOLDER_ID + instance, null);
+ }
+
+ public static String getTheme(Context context) {
+ SharedPreferences prefs = getPreferences(context);
+ return prefs.getString(Constants.PREFERENCES_KEY_THEME, null);
+ }
+
+ public static int getMaxBitrate(Context context) {
+ ConnectivityManager manager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
+ NetworkInfo networkInfo = manager.getActiveNetworkInfo();
+ if (networkInfo == null) {
+ return 0;
+ }
+
+ boolean wifi = networkInfo.getType() == ConnectivityManager.TYPE_WIFI;
+ SharedPreferences prefs = getPreferences(context);
+ return Integer.parseInt(prefs.getString(wifi ? Constants.PREFERENCES_KEY_MAX_BITRATE_WIFI : Constants.PREFERENCES_KEY_MAX_BITRATE_MOBILE, "0"));
+ }
+
+ public static int getPreloadCount(Context context) {
+ SharedPreferences prefs = getPreferences(context);
+ int preloadCount = Integer.parseInt(prefs.getString(Constants.PREFERENCES_KEY_PRELOAD_COUNT, "-1"));
+ return preloadCount == -1 ? Integer.MAX_VALUE : preloadCount;
+ }
+
+ public static int getCacheSizeMB(Context context) {
+ SharedPreferences prefs = getPreferences(context);
+ int cacheSize = Integer.parseInt(prefs.getString(Constants.PREFERENCES_KEY_CACHE_SIZE, "-1"));
+ return cacheSize == -1 ? Integer.MAX_VALUE : cacheSize;
+ }
+
+ public static String getRestUrl(Context context, String method) {
+ StringBuilder builder = new StringBuilder();
+
+ SharedPreferences prefs = getPreferences(context);
+
+ int instance = prefs.getInt(Constants.PREFERENCES_KEY_SERVER_INSTANCE, 1);
+ String serverUrl = prefs.getString(Constants.PREFERENCES_KEY_SERVER_URL + instance, null);
+ String username = prefs.getString(Constants.PREFERENCES_KEY_USERNAME + instance, null);
+ String password = prefs.getString(Constants.PREFERENCES_KEY_PASSWORD + instance, null);
+
+ // Slightly obfuscate password
+ password = "enc:" + Util.utf8HexEncode(password);
+
+ builder.append(serverUrl);
+ if (builder.charAt(builder.length() - 1) != '/') {
+ builder.append("/");
+ }
+ builder.append("rest/").append(method).append(".view");
+ builder.append("?u=").append(username);
+ builder.append("&p=").append(password);
+ builder.append("&v=").append(Constants.REST_PROTOCOL_VERSION);
+ builder.append("&c=").append(Constants.REST_CLIENT_ID);
+
+ return builder.toString();
+ }
+
+ public static SharedPreferences getPreferences(Context context) {
+ return context.getSharedPreferences(Constants.PREFERENCES_FILE_NAME, 0);
+ }
+
+ public static String getContentType(HttpEntity entity) {
+ if (entity == null || entity.getContentType() == null) {
+ return null;
+ }
+ return entity.getContentType().getValue();
+ }
+
+ public static int getRemainingTrialDays(Context context) {
+ SharedPreferences prefs = getPreferences(context);
+ long installTime = prefs.getLong(Constants.PREFERENCES_KEY_INSTALL_TIME, 0L);
+
+ if (installTime == 0L) {
+ installTime = System.currentTimeMillis();
+ SharedPreferences.Editor editor = prefs.edit();
+ editor.putLong(Constants.PREFERENCES_KEY_INSTALL_TIME, installTime);
+ editor.commit();
+ }
+
+ long now = System.currentTimeMillis();
+ long millisPerDay = 24L * 60L * 60L * 1000L;
+ int daysSinceInstall = (int) ((now - installTime) / millisPerDay);
+ return Math.max(0, Constants.FREE_TRIAL_DAYS - daysSinceInstall);
+ }
+
+ /**
+ * Get the contents of an <code>InputStream</code> as a <code>byte[]</code>.
+ * <p/>
+ * This method buffers the input internally, so there is no need to use a
+ * <code>BufferedInputStream</code>.
+ *
+ * @param input the <code>InputStream</code> to read from
+ * @return the requested byte array
+ * @throws NullPointerException if the input is null
+ * @throws IOException if an I/O error occurs
+ */
+ public static byte[] toByteArray(InputStream input) throws IOException {
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ copy(input, output);
+ return output.toByteArray();
+ }
+
+ public static long copy(InputStream input, OutputStream output)
+ throws IOException {
+ byte[] buffer = new byte[1024 * 4];
+ long count = 0;
+ int n;
+ while (-1 != (n = input.read(buffer))) {
+ output.write(buffer, 0, n);
+ count += n;
+ }
+ return count;
+ }
+
+ public static void atomicCopy(File from, File to) throws IOException {
+ FileInputStream in = null;
+ FileOutputStream out = null;
+ File tmp = null;
+ try {
+ tmp = new File(to.getPath() + ".tmp");
+ in = new FileInputStream(from);
+ out = new FileOutputStream(tmp);
+ in.getChannel().transferTo(0, from.length(), out.getChannel());
+ out.close();
+ if (!tmp.renameTo(to)) {
+ throw new IOException("Failed to rename " + tmp + " to " + to);
+ }
+ Log.i(TAG, "Copied " + from + " to " + to);
+ } catch (IOException x) {
+ close(out);
+ delete(to);
+ throw x;
+ } finally {
+ close(in);
+ close(out);
+ delete(tmp);
+ }
+ }
+
+ public static void close(Closeable closeable) {
+ try {
+ if (closeable != null) {
+ closeable.close();
+ }
+ } catch (Throwable x) {
+ // Ignored
+ }
+ }
+
+ public static boolean delete(File file) {
+ if (file != null && file.exists()) {
+ if (!file.delete()) {
+ Log.w(TAG, "Failed to delete file " + file);
+ return false;
+ }
+ Log.i(TAG, "Deleted file " + file);
+ }
+ return true;
+ }
+
+ public static void toast(Context context, int messageId) {
+ toast(context, messageId, true);
+ }
+
+ public static void toast(Context context, int messageId, boolean shortDuration) {
+ toast(context, context.getString(messageId), shortDuration);
+ }
+
+ public static void toast(Context context, String message) {
+ toast(context, message, true);
+ }
+
+ public static void toast(Context context, String message, boolean shortDuration) {
+ if (toast == null) {
+ toast = Toast.makeText(context, message, shortDuration ? Toast.LENGTH_SHORT : Toast.LENGTH_LONG);
+ toast.setGravity(Gravity.CENTER, 0, 0);
+ } else {
+ toast.setText(message);
+ toast.setDuration(shortDuration ? Toast.LENGTH_SHORT : Toast.LENGTH_LONG);
+ }
+ toast.show();
+ }
+
+ /**
+ * Converts a byte-count to a formatted string suitable for display to the user.
+ * For instance:
+ * <ul>
+ * <li><code>format(918)</code> returns <em>"918 B"</em>.</li>
+ * <li><code>format(98765)</code> returns <em>"96 KB"</em>.</li>
+ * <li><code>format(1238476)</code> returns <em>"1.2 MB"</em>.</li>
+ * </ul>
+ * This method assumes that 1 KB is 1024 bytes.
+ * To get a localized string, please use formatLocalizedBytes instead.
+ *
+ * @param byteCount The number of bytes.
+ * @return The formatted string.
+ */
+ public static synchronized String formatBytes(long byteCount) {
+
+ // More than 1 GB?
+ if (byteCount >= 1024 * 1024 * 1024) {
+ NumberFormat gigaByteFormat = GIGA_BYTE_FORMAT;
+ return gigaByteFormat.format((double) byteCount / (1024 * 1024 * 1024));
+ }
+
+ // More than 1 MB?
+ if (byteCount >= 1024 * 1024) {
+ NumberFormat megaByteFormat = MEGA_BYTE_FORMAT;
+ return megaByteFormat.format((double) byteCount / (1024 * 1024));
+ }
+
+ // More than 1 KB?
+ if (byteCount >= 1024) {
+ NumberFormat kiloByteFormat = KILO_BYTE_FORMAT;
+ return kiloByteFormat.format((double) byteCount / 1024);
+ }
+
+ return byteCount + " B";
+ }
+
+ /**
+ * Converts a byte-count to a formatted string suitable for display to the user.
+ * For instance:
+ * <ul>
+ * <li><code>format(918)</code> returns <em>"918 B"</em>.</li>
+ * <li><code>format(98765)</code> returns <em>"96 KB"</em>.</li>
+ * <li><code>format(1238476)</code> returns <em>"1.2 MB"</em>.</li>
+ * </ul>
+ * This method assumes that 1 KB is 1024 bytes.
+ * This version of the method returns a localized string.
+ *
+ * @param byteCount The number of bytes.
+ * @return The formatted string.
+ */
+ public static synchronized String formatLocalizedBytes(long byteCount, Context context) {
+
+ // More than 1 GB?
+ if (byteCount >= 1024 * 1024 * 1024) {
+ if (GIGA_BYTE_LOCALIZED_FORMAT == null) {
+ GIGA_BYTE_LOCALIZED_FORMAT = new DecimalFormat(context.getResources().getString(R.string.util_bytes_format_gigabyte));
+ }
+
+ return GIGA_BYTE_LOCALIZED_FORMAT.format((double) byteCount / (1024 * 1024 * 1024));
+ }
+
+ // More than 1 MB?
+ if (byteCount >= 1024 * 1024) {
+ if (MEGA_BYTE_LOCALIZED_FORMAT == null) {
+ MEGA_BYTE_LOCALIZED_FORMAT = new DecimalFormat(context.getResources().getString(R.string.util_bytes_format_megabyte));
+ }
+
+ return MEGA_BYTE_LOCALIZED_FORMAT.format((double) byteCount / (1024 * 1024));
+ }
+
+ // More than 1 KB?
+ if (byteCount >= 1024) {
+ if (KILO_BYTE_LOCALIZED_FORMAT == null) {
+ KILO_BYTE_LOCALIZED_FORMAT = new DecimalFormat(context.getResources().getString(R.string.util_bytes_format_kilobyte));
+ }
+
+ return KILO_BYTE_LOCALIZED_FORMAT.format((double) byteCount / 1024);
+ }
+
+ if (BYTE_LOCALIZED_FORMAT == null) {
+ BYTE_LOCALIZED_FORMAT = new DecimalFormat(context.getResources().getString(R.string.util_bytes_format_byte));
+ }
+
+ return BYTE_LOCALIZED_FORMAT.format((double) byteCount);
+ }
+
+ public static String formatDuration(Integer seconds) {
+ if (seconds == null) {
+ return null;
+ }
+
+ int minutes = seconds / 60;
+ int secs = seconds % 60;
+
+ StringBuilder builder = new StringBuilder(6);
+ builder.append(minutes).append(":");
+ if (secs < 10) {
+ builder.append("0");
+ }
+ builder.append(secs);
+ return builder.toString();
+ }
+
+ public static boolean equals(Object object1, Object object2) {
+ if (object1 == object2) {
+ return true;
+ }
+ if (object1 == null || object2 == null) {
+ return false;
+ }
+ return object1.equals(object2);
+
+ }
+
+ /**
+ * Encodes the given string by using the hexadecimal representation of its UTF-8 bytes.
+ *
+ * @param s The string to encode.
+ * @return The encoded string.
+ */
+ public static String utf8HexEncode(String s) {
+ if (s == null) {
+ return null;
+ }
+ byte[] utf8;
+ try {
+ utf8 = s.getBytes(Constants.UTF_8);
+ } catch (UnsupportedEncodingException x) {
+ throw new RuntimeException(x);
+ }
+ return hexEncode(utf8);
+ }
+
+ /**
+ * Converts an array of bytes into an array of characters representing the hexadecimal values of each byte in order.
+ * The returned array will be double the length of the passed array, as it takes two characters to represent any
+ * given byte.
+ *
+ * @param data Bytes to convert to hexadecimal characters.
+ * @return A string containing hexadecimal characters.
+ */
+ public static String hexEncode(byte[] data) {
+ int length = data.length;
+ char[] out = new char[length << 1];
+ // two characters form the hex value.
+ for (int i = 0, j = 0; i < length; i++) {
+ out[j++] = HEX_DIGITS[(0xF0 & data[i]) >>> 4];
+ out[j++] = HEX_DIGITS[0x0F & data[i]];
+ }
+ return new String(out);
+ }
+
+ /**
+ * Calculates the MD5 digest and returns the value as a 32 character hex string.
+ *
+ * @param s Data to digest.
+ * @return MD5 digest as a hex string.
+ */
+ public static String md5Hex(String s) {
+ if (s == null) {
+ return null;
+ }
+
+ try {
+ MessageDigest md5 = MessageDigest.getInstance("MD5");
+ return hexEncode(md5.digest(s.getBytes(Constants.UTF_8)));
+ } catch (Exception x) {
+ throw new RuntimeException(x.getMessage(), x);
+ }
+ }
+
+ public static boolean isNetworkConnected(Context context) {
+ ConnectivityManager manager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
+ NetworkInfo networkInfo = manager.getActiveNetworkInfo();
+ boolean connected = networkInfo != null && networkInfo.isConnected();
+
+ boolean wifiConnected = connected && networkInfo.getType() == ConnectivityManager.TYPE_WIFI;
+ boolean wifiRequired = isWifiRequiredForDownload(context);
+
+ return connected && (!wifiRequired || wifiConnected);
+ }
+
+ public static boolean isExternalStoragePresent() {
+ return Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState());
+ }
+
+ private static boolean isWifiRequiredForDownload(Context context) {
+ SharedPreferences prefs = getPreferences(context);
+ return prefs.getBoolean(Constants.PREFERENCES_KEY_WIFI_REQUIRED_FOR_DOWNLOAD, false);
+ }
+
+ public static void info(Context context, int titleId, int messageId) {
+ showDialog(context, android.R.drawable.ic_dialog_info, titleId, messageId);
+ }
+
+ private static void showDialog(Context context, int icon, int titleId, int messageId) {
+ new AlertDialog.Builder(context)
+ .setIcon(icon)
+ .setTitle(titleId)
+ .setMessage(messageId)
+ .setPositiveButton(R.string.common_ok, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int i) {
+ dialog.dismiss();
+ }
+ })
+ .show();
+ }
+
+ public static void showPlayingNotification(final Context context, final DownloadServiceImpl downloadService, Handler handler, MusicDirectory.Entry song) {
+
+ // Use the same text for the ticker and the expanded notification
+ String title = song.getTitle();
+ String text = song.getArtist();
+
+ // Set the icon, scrolling text and timestamp
+ final Notification notification = new Notification(R.drawable.stat_notify_playing, title, System.currentTimeMillis());
+ notification.flags |= Notification.FLAG_NO_CLEAR | Notification.FLAG_ONGOING_EVENT;
+
+ RemoteViews contentView = new RemoteViews(context.getPackageName(), R.layout.notification);
+
+ // Set the album art.
+ try {
+ int size = context.getResources().getDrawable(R.drawable.unknown_album).getIntrinsicHeight();
+ Bitmap bitmap = FileUtil.getAlbumArtBitmap(context, song, size);
+ if (bitmap == null) {
+ // set default album art
+ contentView.setImageViewResource(R.id.notification_image, R.drawable.unknown_album);
+ } else {
+ contentView.setImageViewBitmap(R.id.notification_image, bitmap);
+ }
+ } catch (Exception x) {
+ Log.w(TAG, "Failed to get notification cover art", x);
+ contentView.setImageViewResource(R.id.notification_image, R.drawable.unknown_album);
+ }
+
+ // set the text for the notifications
+ contentView.setTextViewText(R.id.notification_title, title);
+ contentView.setTextViewText(R.id.notification_artist, text);
+
+ Pair<Integer, Integer> colors = getNotificationTextColors(context);
+ if (colors.getFirst() != null) {
+ contentView.setTextColor(R.id.notification_title, colors.getFirst());
+ }
+ if (colors.getSecond() != null) {
+ contentView.setTextColor(R.id.notification_artist, colors.getSecond());
+ }
+
+ notification.contentView = contentView;
+
+ Intent notificationIntent = new Intent(context, DownloadActivity.class);
+ notification.contentIntent = PendingIntent.getActivity(context, 0, notificationIntent, 0);
+
+ // Send the notification and put the service in the foreground.
+ handler.post(new Runnable() {
+ @Override
+ public void run() {
+ startForeground(downloadService, Constants.NOTIFICATION_ID_PLAYING, notification);
+ }
+ });
+
+ // Update widget
+ SubsonicAppWidgetProvider.getInstance().notifyChange(context, downloadService, true);
+ }
+
+ public static void hidePlayingNotification(final Context context, final DownloadServiceImpl downloadService, Handler handler) {
+
+ // Remove notification and remove the service from the foreground
+ handler.post(new Runnable() {
+ @Override
+ public void run() {
+ stopForeground(downloadService, true);
+ }
+ });
+
+ // Update widget
+ SubsonicAppWidgetProvider.getInstance().notifyChange(context, downloadService, false);
+ }
+
+ public static void sleepQuietly(long millis) {
+ try {
+ Thread.sleep(millis);
+ } catch (InterruptedException x) {
+ Log.w(TAG, "Interrupted from sleep.", x);
+ }
+ }
+
+ public static void startActivityWithoutTransition(Activity currentActivity, Class<? extends Activity> newActivitiy) {
+ startActivityWithoutTransition(currentActivity, new Intent(currentActivity, newActivitiy));
+ }
+
+ public static void startActivityWithoutTransition(Activity currentActivity, Intent intent) {
+ currentActivity.startActivity(intent);
+ disablePendingTransition(currentActivity);
+ }
+
+ public static void disablePendingTransition(Activity activity) {
+
+ // Activity.overridePendingTransition() was introduced in Android 2.0. Use reflection to maintain
+ // compatibility with 1.5.
+ try {
+ Method method = Activity.class.getMethod("overridePendingTransition", int.class, int.class);
+ method.invoke(activity, 0, 0);
+ } catch (Throwable x) {
+ // Ignored
+ }
+ }
+
+ public static Drawable createDrawableFromBitmap(Context context, Bitmap bitmap) {
+ // BitmapDrawable(Resources, Bitmap) was introduced in Android 1.6. Use reflection to maintain
+ // compatibility with 1.5.
+ try {
+ Constructor<BitmapDrawable> constructor = BitmapDrawable.class.getConstructor(Resources.class, Bitmap.class);
+ return constructor.newInstance(context.getResources(), bitmap);
+ } catch (Throwable x) {
+ return new BitmapDrawable(bitmap);
+ }
+ }
+
+ public static void registerMediaButtonEventReceiver(Context context) {
+
+ // Only do it if enabled in the settings.
+ SharedPreferences prefs = getPreferences(context);
+ boolean enabled = prefs.getBoolean(Constants.PREFERENCES_KEY_MEDIA_BUTTONS, true);
+
+ if (enabled) {
+
+ // AudioManager.registerMediaButtonEventReceiver() was introduced in Android 2.2.
+ // Use reflection to maintain compatibility with 1.5.
+ try {
+ AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
+ ComponentName componentName = new ComponentName(context.getPackageName(), MediaButtonIntentReceiver.class.getName());
+ Method method = AudioManager.class.getMethod("registerMediaButtonEventReceiver", ComponentName.class);
+ method.invoke(audioManager, componentName);
+ } catch (Throwable x) {
+ // Ignored.
+ }
+ }
+ }
+
+ public static void unregisterMediaButtonEventReceiver(Context context) {
+ // AudioManager.unregisterMediaButtonEventReceiver() was introduced in Android 2.2.
+ // Use reflection to maintain compatibility with 1.5.
+ try {
+ AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
+ ComponentName componentName = new ComponentName(context.getPackageName(), MediaButtonIntentReceiver.class.getName());
+ Method method = AudioManager.class.getMethod("unregisterMediaButtonEventReceiver", ComponentName.class);
+ method.invoke(audioManager, componentName);
+ } catch (Throwable x) {
+ // Ignored.
+ }
+ }
+
+ private static void startForeground(Service service, int notificationId, Notification notification) {
+ // Service.startForeground() was introduced in Android 2.0.
+ // Use reflection to maintain compatibility with 1.5.
+ try {
+ Method method = Service.class.getMethod("startForeground", int.class, Notification.class);
+ method.invoke(service, notificationId, notification);
+ Log.i(TAG, "Successfully invoked Service.startForeground()");
+ } catch (Throwable x) {
+ NotificationManager notificationManager = (NotificationManager) service.getSystemService(Context.NOTIFICATION_SERVICE);
+ notificationManager.notify(Constants.NOTIFICATION_ID_PLAYING, notification);
+ Log.i(TAG, "Service.startForeground() not available. Using work-around.");
+ }
+ }
+
+ private static void stopForeground(Service service, boolean removeNotification) {
+ // Service.stopForeground() was introduced in Android 2.0.
+ // Use reflection to maintain compatibility with 1.5.
+ try {
+ Method method = Service.class.getMethod("stopForeground", boolean.class);
+ method.invoke(service, removeNotification);
+ Log.i(TAG, "Successfully invoked Service.stopForeground()");
+ } catch (Throwable x) {
+ NotificationManager notificationManager = (NotificationManager) service.getSystemService(Context.NOTIFICATION_SERVICE);
+ notificationManager.cancel(Constants.NOTIFICATION_ID_PLAYING);
+ Log.i(TAG, "Service.stopForeground() not available. Using work-around.");
+ }
+ }
+
+ /**
+ * <p>Broadcasts the given song info as the new song being played.</p>
+ */
+ public static void broadcastNewTrackInfo(Context context, MusicDirectory.Entry song) {
+ Intent intent = new Intent(EVENT_META_CHANGED);
+
+ if (song != null) {
+ intent.putExtra("title", song.getTitle());
+ intent.putExtra("artist", song.getArtist());
+ intent.putExtra("album", song.getAlbum());
+
+ File albumArtFile = FileUtil.getAlbumArtFile(context, song);
+ intent.putExtra("coverart", albumArtFile.getAbsolutePath());
+ } else {
+ intent.putExtra("title", "");
+ intent.putExtra("artist", "");
+ intent.putExtra("album", "");
+ intent.putExtra("coverart", "");
+ }
+
+ context.sendBroadcast(intent);
+ }
+
+ /**
+ * <p>Broadcasts the given player state as the one being set.</p>
+ */
+ public static void broadcastPlaybackStatusChange(Context context, PlayerState state) {
+ Intent intent = new Intent(EVENT_PLAYSTATE_CHANGED);
+
+ switch (state) {
+ case STARTED:
+ intent.putExtra("state", "play");
+ break;
+ case STOPPED:
+ intent.putExtra("state", "stop");
+ break;
+ case PAUSED:
+ intent.putExtra("state", "pause");
+ break;
+ case COMPLETED:
+ intent.putExtra("state", "complete");
+ break;
+ default:
+ return; // No need to broadcast.
+ }
+
+ context.sendBroadcast(intent);
+ }
+
+ /**
+ * Resolves the default text color for notifications.
+ *
+ * Based on http://stackoverflow.com/questions/4867338/custom-notification-layouts-and-text-colors/7320604#7320604
+ */
+ private static Pair<Integer, Integer> getNotificationTextColors(Context context) {
+ if (NOTIFICATION_TEXT_COLORS.getFirst() == null && NOTIFICATION_TEXT_COLORS.getSecond() == null) {
+ try {
+ Notification notification = new Notification();
+ String title = "title";
+ String content = "content";
+ notification.setLatestEventInfo(context, title, content, null);
+ LinearLayout group = new LinearLayout(context);
+ ViewGroup event = (ViewGroup) notification.contentView.apply(context, group);
+ findNotificationTextColors(event, title, content);
+ group.removeAllViews();
+ } catch (Exception x) {
+ Log.w(TAG, "Failed to resolve notification text colors.", x);
+ }
+ }
+ return NOTIFICATION_TEXT_COLORS;
+ }
+
+ private static void findNotificationTextColors(ViewGroup group, String title, String content) {
+ for (int i = 0; i < group.getChildCount(); i++) {
+ if (group.getChildAt(i) instanceof TextView) {
+ TextView textView = (TextView) group.getChildAt(i);
+ String text = textView.getText().toString();
+ if (title.equals(text)) {
+ NOTIFICATION_TEXT_COLORS.setFirst(textView.getTextColors().getDefaultColor());
+ }
+ else if (content.equals(text)) {
+ NOTIFICATION_TEXT_COLORS.setSecond(textView.getTextColors().getDefaultColor());
+ }
+ }
+ else if (group.getChildAt(i) instanceof ViewGroup)
+ findNotificationTextColors((ViewGroup) group.getChildAt(i), title, content);
+ }
+ }
+}
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/view/VisualizerView.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/view/VisualizerView.java
new file mode 100644
index 00000000..76a45253
--- /dev/null
+++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/view/VisualizerView.java
@@ -0,0 +1,132 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
+
+ Copyright 2011 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.view;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.media.audiofx.Visualizer;
+import android.util.AttributeSet;
+import android.view.View;
+import net.sourceforge.subsonic.androidapp.audiofx.VisualizerController;
+import net.sourceforge.subsonic.androidapp.domain.PlayerState;
+import net.sourceforge.subsonic.androidapp.service.DownloadService;
+import net.sourceforge.subsonic.androidapp.service.DownloadServiceImpl;
+
+/**
+ * A simple class that draws waveform data received from a
+ * {@link Visualizer.OnDataCaptureListener#onWaveFormDataCapture}
+ *
+ * @author Sindre Mehus
+ * @version $Id$
+ */
+public class VisualizerView extends View {
+
+ private static final int PREFERRED_CAPTURE_RATE_MILLIHERTZ = 20000;
+
+ private final Paint paint = new Paint();
+
+ private byte[] data;
+ private float[] points;
+ private boolean active;
+
+ public VisualizerView(Context context) {
+ super(context);
+
+ paint.setStrokeWidth(2f);
+ paint.setAntiAlias(true);
+ paint.setColor(Color.rgb(129, 201, 54));
+ }
+
+ public boolean isActive() {
+ return active;
+ }
+
+ public void setActive(boolean active) {
+ this.active = active;
+ Visualizer visualizer = getVizualiser();
+ if (visualizer == null) {
+ return;
+ }
+
+ int captureRate = Math.min(PREFERRED_CAPTURE_RATE_MILLIHERTZ, Visualizer.getMaxCaptureRate());
+ if (active) {
+ visualizer.setDataCaptureListener(new Visualizer.OnDataCaptureListener() {
+ @Override
+ public void onWaveFormDataCapture(Visualizer visualizer, byte[] waveform, int samplingRate) {
+ updateVisualizer(waveform);
+ }
+
+ @Override
+ public void onFftDataCapture(Visualizer visualizer, byte[] fft, int samplingRate) {
+ }
+ }, captureRate, true, false);
+ } else {
+ visualizer.setDataCaptureListener(null, captureRate, false, false);
+ }
+
+ visualizer.setEnabled(active);
+ invalidate();
+ }
+
+ private Visualizer getVizualiser() {
+ DownloadService downloadService = DownloadServiceImpl.getInstance();
+ VisualizerController visualizerController = downloadService == null ? null : downloadService.getVisualizerController();
+ return visualizerController == null ? null : visualizerController.getVisualizer();
+ }
+
+ private void updateVisualizer(byte[] waveform) {
+ this.data = waveform;
+ invalidate();
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ super.onDraw(canvas);
+
+ if (!active) {
+ return;
+ }
+ DownloadService downloadService = DownloadServiceImpl.getInstance();
+ if (downloadService != null && downloadService.getPlayerState() != PlayerState.STARTED) {
+ return;
+ }
+
+ if (data == null) {
+ return;
+ }
+
+ if (points == null || points.length < data.length * 4) {
+ points = new float[data.length * 4];
+ }
+
+ int w = getWidth();
+ int h = getHeight();
+
+ for (int i = 0; i < data.length - 1; i++) {
+ points[i * 4] = w * i / (data.length - 1);
+ points[i * 4 + 1] = h / 2 + ((byte) (data[i] + 128)) * (h / 2) / 128;
+ points[i * 4 + 2] = w * (i + 1) / (data.length - 1);
+ points[i * 4 + 3] = h / 2 + ((byte) (data[i + 1] + 128)) * (h / 2) / 128;
+ }
+
+ canvas.drawLines(points, paint);
+ }
+}