diff options
author | Scott Jackson <daneren2005@gmail.com> | 2012-07-02 21:24:02 -0700 |
---|---|---|
committer | Scott Jackson <daneren2005@gmail.com> | 2012-07-02 21:24:02 -0700 |
commit | a1a18f77a50804e0127dfa4b0f5240c49c541184 (patch) | |
tree | 19a38880afe505beddb5590379a8134d7730a277 /subsonic-android/src/net/sourceforge/subsonic/androidapp | |
parent | b61d787706979e7e20f4c3c4f93c1f129d92273f (diff) | |
download | dsub-a1a18f77a50804e0127dfa4b0f5240c49c541184.tar.gz dsub-a1a18f77a50804e0127dfa4b0f5240c49c541184.tar.bz2 dsub-a1a18f77a50804e0127dfa4b0f5240c49c541184.zip |
Initial Commit
Diffstat (limited to 'subsonic-android/src/net/sourceforge/subsonic/androidapp')
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); + } +} |