Files tab

This commit is contained in:
Christopher Eby 2011-11-06 01:48:06 -05:00
parent 2569247cbf
commit 3f26fd0965
18 changed files with 740 additions and 206 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1003 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 764 B

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/ic_tab_files_selected" android:state_selected="true" android:state_pressed="false" />
<item android:drawable="@drawable/ic_tab_files_unselected" />
</selector>

View File

@ -65,12 +65,21 @@ THE SOFTWARE.
android:visibility="gone"
android:layout_width="fill_parent"
android:layout_height="fill_parent" />
<ListView
android:id="@+id/file_list"
android:visibility="gone"
android:layout_width="fill_parent"
android:layout_height="fill_parent" />
</FrameLayout>
<LinearLayout
android:id="@+id/limiter_layout"
android:layout_width="wrap_content"
<HorizontalScrollView
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom|left" />
android:layout_gravity="bottom|left">
<LinearLayout
android:id="@+id/limiter_layout"
android:layout_width="fill_parent"
android:layout_height="fill_parent" />
</HorizontalScrollView>
<LinearLayout
android:id="@+id/search_box"
android:layout_width="fill_parent"

View File

@ -62,7 +62,6 @@ THE SOFTWARE.
<item quantity="one">1 låt lagt til spilleliste %2$s.</item>
<item quantity="other">%1$d låter lagt til spilleliste %2$s.</item>
</plurals>
<string name="playlist_deleted">Spilleliste %s slettet.</string>
<plurals name="deleted">
<item quantity="one">1 låt slettet.</item>
<item quantity="other">%d låter slettet.</item>

View File

@ -65,7 +65,6 @@ THE SOFTWARE.
<item quantity="few">%1$d skladby pridané do zoznamu skladieb %2$s.</item>
<item quantity="other">%1$d skladieb pridaných do zoznamu skladieb %2$s.</item>
</plurals>
<string name="playlist_deleted">Zoznam skladieb %s odstránený.</string>
<plurals name="deleted">
<item quantity="one">1 skladba odstránená.</item>
<item quantity="few">%d skladby odstránené.</item>

View File

@ -74,17 +74,19 @@ THE SOFTWARE.
<item quantity="one">1 song added to playlist %2$s.</item>
<item quantity="other">%1$d songs added to playlist %2$s.</item>
</plurals>
<string name="playlist_deleted">Playlist %s deleted.</string>
<string name="deleted">%s deleted.</string>
<plurals name="deleted">
<item quantity="one">1 song deleted.</item>
<item quantity="other">%d songs deleted.</item>
</plurals>
<string name="delete_file_failed">Failed to delete %s.</string>
<string name="artists">Artists</string>
<string name="albums">Albums</string>
<string name="songs">Songs</string>
<string name="playlists">Playlists</string>
<string name="genres">Genres</string>
<string name="files">Files</string>
<string name="none">None</string>
<string name="unknown">Unknown</string>

View File

@ -0,0 +1,253 @@
/*
* Copyright (C) 2010, 2011 Christopher Eby <kreed@kreed.org>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package org.kreed.vanilla;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.os.Environment;
import android.os.FileObserver;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import java.io.File;
import java.io.FilenameFilter;
import java.util.Arrays;
import java.util.Comparator;
import java.util.regex.Pattern;
/**
* A list adapter that provides a view of the filesystem. The active directory
* is set through a {@link Limiter} and rows are displayed using MediaViews.
*/
public class FileSystemAdapter extends BaseAdapter implements LibraryAdapter {
private static final Pattern SPACE_SPLIT = Pattern.compile("\\s+");
private static final Pattern FILE_SEPARATOR = Pattern.compile(File.separator);
/**
* The owner LibraryActivity.
*/
final LibraryActivity mActivity;
/**
* The currently active limiter, set by a row expander being clicked.
*/
private Limiter mLimiter;
/**
* The files and folders in the current directory.
*/
private File[] mFiles;
/**
* The folder icon shown for folder rows.
*/
private final Bitmap mFolderIcon;
/**
* The currently active filter, entered by the user from the search box.
*/
String[] mFilter;
/**
* Excludes dot files and files not matching mFilter.
*/
private final FilenameFilter mFileFilter = new FilenameFilter() {
@Override
public boolean accept(File dir, String filename)
{
if (filename.charAt(0) == '.')
return false;
if (mFilter != null) {
filename = filename.toLowerCase();
for (String term : mFilter) {
if (!filename.contains(term))
return false;
}
}
return true;
}
};
/**
* Sorts folders before files first, then sorts alphabetically by name.
*/
private final Comparator<File> mFileComparator = new Comparator<File>() {
@Override
public int compare(File a, File b)
{
boolean aIsFolder = a.isDirectory();
boolean bIsFolder = b.isDirectory();
if (bIsFolder == aIsFolder) {
return a.getName().compareToIgnoreCase(b.getName());
} else if (bIsFolder) {
return 1;
}
return -1;
}
};
/**
* The Observer instance for the current directory.
*/
private Observer mFileObserver;
/**
* Create a FileSystemAdapter.
*
* @param activity The LibraryActivity that will contain this adapter.
* Called on to requery this adapter when the contents of the directory
* change.
* @param limiter An initial limiter to set. If none is given, will be set
* to the external storage directory.
*/
public FileSystemAdapter(LibraryActivity activity, Limiter limiter)
{
mActivity = activity;
mLimiter = limiter;
mFolderIcon = BitmapFactory.decodeResource(activity.getResources(), R.drawable.ic_launcher_folder);
if (limiter == null) {
limiter = buildLimiter(Environment.getExternalStorageDirectory());
}
setLimiter(limiter);
}
@Override
public Object query()
{
File file = mLimiter == null ? new File("/") : (File)mLimiter.data;
if (mFileObserver == null) {
mFileObserver = new Observer(file.getPath());
}
File[] files = file.listFiles(mFileFilter);
if (files != null)
Arrays.sort(files, mFileComparator);
return files;
}
@Override
public void commitQuery(Object data)
{
mFiles = (File[])data;
notifyDataSetInvalidated();
}
@Override
public void clear()
{
mFiles = null;
notifyDataSetInvalidated();
}
@Override
public int getCount()
{
if (mFiles == null)
return 0;
return mFiles.length;
}
@Override
public Object getItem(int pos)
{
return mFiles[pos];
}
@Override
public long getItemId(int pos)
{
return pos;
}
@Override
public View getView(int pos, View convertView, ViewGroup parent)
{
MediaView view;
if (convertView == null) {
view = new MediaView(mActivity, mFolderIcon, MediaView.sExpander);
} else {
view = (MediaView)convertView;
}
File file = mFiles[pos];
view.setData(pos, file.getName());
view.setShowBitmaps(file.isDirectory());
return view;
}
@Override
public void setFilter(String filter)
{
mFilter = SPACE_SPLIT.split(filter.toLowerCase());
}
@Override
public void setLimiter(Limiter limiter)
{
if (mFileObserver != null)
mFileObserver.stopWatching();
mFileObserver = null;
mLimiter = limiter;
}
@Override
public Limiter getLimiter()
{
return mLimiter;
}
/**
* Builds a limiter from the given folder. Only files contained in the
* given folder will be shown if the limiter is set on this adapter.
*
* @param file A File pointing to a folder.
* @return A limiter describing the given folder.
*/
public static Limiter buildLimiter(File file)
{
String[] fields = FILE_SEPARATOR.split(file.getPath().substring(1));
return new Limiter(MediaUtils.TYPE_FILE, fields, file);
}
@Override
public Limiter buildLimiter(long id)
{
return buildLimiter(mFiles[(int)id]);
}
@Override
public int getMediaType()
{
return MediaUtils.TYPE_FILE;
}
/**
* FileObserver that reloads the files in this adapter.
*/
private class Observer extends FileObserver {
public Observer(String path)
{
super(path, FileObserver.CREATE | FileObserver.DELETE | FileObserver.MOVED_TO | FileObserver.MOVED_FROM);
startWatching();
}
@Override
public void onEvent(int event, String path)
{
mActivity.postRequestRequery(FileSystemAdapter.this);
}
}
}

View File

@ -26,8 +26,8 @@ import android.app.AlertDialog;
import android.content.ContentResolver;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.res.Resources;
import android.content.SharedPreferences;
import android.content.res.Resources;
import android.database.ContentObserver;
import android.database.Cursor;
import android.graphics.Color;
@ -42,6 +42,7 @@ import android.provider.MediaStore;
import android.text.Editable;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.util.Log;
import android.view.ContextMenu;
import android.view.KeyEvent;
import android.view.LayoutInflater;
@ -59,6 +60,8 @@ import android.widget.RadioGroup;
import android.widget.TabHost;
import android.widget.TextView;
import android.widget.Toast;
import java.io.File;
import java.io.IOException;
import junit.framework.Assert;
/**
@ -101,19 +104,35 @@ public class LibraryActivity
private int mLastAction = ACTION_PLAY;
private long mLastActedId;
private MediaAdapter[] mAdapters;
private MediaAdapter mArtistAdapter;
private MediaAdapter mAlbumAdapter;
private MediaAdapter mSongAdapter;
private MediaAdapter mPlaylistAdapter;
private MediaAdapter mGenreAdapter;
private MediaAdapter mCurrentAdapter;
/**
* The number of adapters/lists (determines array sizes).
*/
private static final int ADAPTER_COUNT = 6;
/**
* The ListView for each adapter, in the same order as MediaUtils.TYPE_*.
*/
final ListView[] mLists = new ListView[ADAPTER_COUNT];
/**
* Whether the adapter corresponding to each index has stale data.
*/
final boolean[] mRequeryNeeded = new boolean[ADAPTER_COUNT];
/**
* Each adapter, in the same order as MediaUtils.TYPE_*.
*/
final LibraryAdapter[] mAdapters = new LibraryAdapter[ADAPTER_COUNT];
MediaAdapter mArtistAdapter;
MediaAdapter mAlbumAdapter;
MediaAdapter mSongAdapter;
MediaAdapter mPlaylistAdapter;
MediaAdapter mGenreAdapter;
FileSystemAdapter mFilesAdapter;
LibraryAdapter mCurrentAdapter;
private final ContentObserver mPlaylistObserver = new ContentObserver(null) {
@Override
public void onChange(boolean selfChange)
{
mUiHandler.sendMessage(mUiHandler.obtainMessage(MSG_REQUEST_REQUERY, mPlaylistAdapter));
postRequestRequery(mPlaylistAdapter);
}
};
@ -168,13 +187,19 @@ public class LibraryActivity
mTabHost = (TabHost)findViewById(R.id.tab_host);
mTabHost.setup();
mArtistAdapter = setupView(R.id.artist_list, MediaUtils.TYPE_ARTIST, R.string.artists, R.drawable.ic_tab_artists, true, true, null);
mAlbumAdapter = setupView(R.id.album_list, MediaUtils.TYPE_ALBUM, R.string.albums, R.drawable.ic_tab_albums, true, true, state == null ? null : (MediaAdapter.Limiter)state.getSerializable("limiter_albums"));
mSongAdapter = setupView(R.id.song_list, MediaUtils.TYPE_SONG, R.string.songs, R.drawable.ic_tab_songs, false, true, state == null ? null : (MediaAdapter.Limiter)state.getSerializable("limiter_songs"));
mPlaylistAdapter = setupView(R.id.playlist_list, MediaUtils.TYPE_PLAYLIST, R.string.playlists, R.drawable.ic_tab_playlists, true, false, null);
mGenreAdapter = setupView(R.id.genre_list, MediaUtils.TYPE_GENRE, R.string.genres, R.drawable.ic_tab_genres, true, false, state == null ? null : (MediaAdapter.Limiter)state.getSerializable("limiter_genres"));
// These should be in the same order as MediaUtils.TYPE_*
mAdapters = new MediaAdapter[] { mArtistAdapter, mAlbumAdapter, mSongAdapter, mPlaylistAdapter, mGenreAdapter };
mArtistAdapter = new MediaAdapter(this, MediaUtils.TYPE_ARTIST, true, true, null);
mAlbumAdapter = new MediaAdapter(this, MediaUtils.TYPE_ALBUM, true, true, state == null ? null : (Limiter)state.getSerializable("limiter_albums"));
mSongAdapter = new MediaAdapter(this, MediaUtils.TYPE_SONG, false, true, state == null ? null : (Limiter)state.getSerializable("limiter_songs"));
mPlaylistAdapter = new MediaAdapter(this, MediaUtils.TYPE_PLAYLIST, true, false, null);
mGenreAdapter = new MediaAdapter(this, MediaUtils.TYPE_GENRE, true, false, null);
mFilesAdapter = new FileSystemAdapter(this, state == null ? null : (Limiter)state.getSerializable("limiter_files"));
setupView(0, R.id.artist_list, R.string.artists, R.drawable.ic_tab_artists, mArtistAdapter);
setupView(1, R.id.album_list, R.string.albums, R.drawable.ic_tab_albums, mAlbumAdapter);
setupView(2, R.id.song_list, R.string.songs, R.drawable.ic_tab_songs, mSongAdapter);
setupView(3, R.id.playlist_list, R.string.playlists, R.drawable.ic_tab_playlists, mPlaylistAdapter);
setupView(4, R.id.genre_list, R.string.genres, R.drawable.ic_tab_genres, mGenreAdapter);
setupView(5, R.id.file_list, R.string.files, R.drawable.ic_tab_files, mFilesAdapter);
getContentResolver().registerContentObserver(MediaStore.Audio.Playlists.EXTERNAL_CONTENT_URI, true, mPlaylistObserver);
@ -219,7 +244,7 @@ public class LibraryActivity
out.putString("filter", mTextFilter.getText().toString());
out.putSerializable("limiter_albums", mAlbumAdapter.getLimiter());
out.putSerializable("limiter_songs", mSongAdapter.getLimiter());
out.putSerializable("limiter_genres", mGenreAdapter.getLimiter());
out.putSerializable("limiter_files", mFilesAdapter.getLimiter());
}
@Override
@ -282,7 +307,7 @@ public class LibraryActivity
* Adds songs matching the data from the given intent to the song timelime.
*
* @param intent An intent created with
* {@link LibraryActivity#createClickIntent(MediaAdapter,MediaView)}.
* {@link LibraryActivity#createClickIntent(LibraryAdapter,MediaView)}.
* @param action One of LibraryActivity.ACTION_*
*/
private void pickSongs(Intent intent, int action)
@ -295,7 +320,7 @@ public class LibraryActivity
boolean all = false;
int mode = action;
if (action == ACTION_PLAY_ALL || action == ACTION_ENQUEUE_ALL) {
MediaAdapter adapter = mCurrentAdapter;
LibraryAdapter adapter = mCurrentAdapter;
boolean notPlayAllAdapter = (adapter != mSongAdapter && adapter != mAlbumAdapter
&& adapter != mArtistAdapter) || id == MediaView.HEADER_ID;
if (mode == ACTION_ENQUEUE_ALL && notPlayAllAdapter) {
@ -324,13 +349,38 @@ public class LibraryActivity
* from the view and switching to the appropriate tab.
*
* @param intent An intent created with
* {@link LibraryActivity#createClickIntent(MediaAdapter,MediaView)}.
* {@link LibraryActivity#createClickIntent(LibraryAdapter,MediaView)}.
*/
private void expand(Intent intent)
{
int type = intent.getIntExtra("type", 1);
long id = intent.getLongExtra("id", -1);
mTabHost.setCurrentTab(setLimiter(mAdapters[type - 1].getLimiter(id)));
int tab = setLimiter(mAdapters[type - 1].buildLimiter(id));
if (tab == -1 || mTabHost.getCurrentTab() == tab) {
updateLimiterViews();
} else {
mTabHost.setCurrentTab(tab);
}
}
/**
* Clear a limiter.
*
* @param type Which type of limiter to clear.
*/
private void clearLimiter(int type)
{
if (type == MediaUtils.TYPE_FILE) {
mFilesAdapter.setLimiter(null);
requestRequery(mFilesAdapter);
} else {
mAlbumAdapter.setLimiter(null);
mSongAdapter.setLimiter(null);
loadSortOrder(mSongAdapter);
loadSortOrder(mAlbumAdapter);
requestRequery(mSongAdapter);
requestRequery(mAlbumAdapter);
}
}
/**
@ -338,34 +388,32 @@ public class LibraryActivity
*
* @return The tab to "expand" to
*/
private int setLimiter(MediaAdapter.Limiter limiter)
private int setLimiter(Limiter limiter)
{
int tab;
if (limiter == null) {
switch (limiter.type) {
case MediaUtils.TYPE_ALBUM:
mSongAdapter.setLimiter(limiter);
loadSortOrder(mSongAdapter);
requestRequery(mSongAdapter);
return 2;
case MediaUtils.TYPE_ARTIST:
mAlbumAdapter.setLimiter(limiter);
mSongAdapter.setLimiter(limiter);
tab = 1;
break;
case MediaUtils.TYPE_GENRE:
mSongAdapter.setLimiter(limiter);
mAlbumAdapter.setLimiter(null);
mSongAdapter.setLimiter(null);
tab = -1;
} else {
switch (limiter.type) {
case MediaUtils.TYPE_ALBUM:
mSongAdapter.setLimiter(limiter);
loadSortOrder(mSongAdapter);
requestRequery(mSongAdapter);
return 2;
case MediaUtils.TYPE_ARTIST:
mAlbumAdapter.setLimiter(limiter);
mSongAdapter.setLimiter(limiter);
tab = 1;
break;
case MediaUtils.TYPE_GENRE:
mSongAdapter.setLimiter(limiter);
mAlbumAdapter.setLimiter(null);
tab = 2;
break;
default:
throw new IllegalArgumentException("Unsupported limiter type: " + limiter.type);
}
tab = 2;
break;
case MediaUtils.TYPE_FILE:
mFilesAdapter.setLimiter(limiter);
requestRequery(mFilesAdapter);
return 5;
default:
throw new IllegalArgumentException("Unsupported limiter type: " + limiter.type);
}
loadSortOrder(mSongAdapter);
@ -392,7 +440,7 @@ public class LibraryActivity
public void onItemClick(AdapterView<?> list, View view, int pos, long id)
{
MediaView mediaView = (MediaView)view;
MediaAdapter adapter = (MediaAdapter)list.getAdapter();
LibraryAdapter adapter = (LibraryAdapter)list.getAdapter();
if (mediaView.isRightBitmapPressed()) {
if (adapter == mPlaylistAdapter)
editPlaylist(mediaView.getMediaId(), mediaView.getTitle());
@ -419,7 +467,7 @@ public class LibraryActivity
public void onTextChanged(CharSequence text, int start, int before, int count)
{
String filter = text.toString();
for (MediaAdapter adapter : mAdapters) {
for (LibraryAdapter adapter : mAdapters) {
adapter.setFilter(filter);
requestRequery(adapter);
}
@ -432,11 +480,11 @@ public class LibraryActivity
mLimiterViews.removeAllViews();
MediaAdapter adapter = mCurrentAdapter;
LibraryAdapter adapter = mCurrentAdapter;
if (adapter == null)
return;
MediaAdapter.Limiter limiterData = adapter.getLimiter();
Limiter limiterData = adapter.getLimiter();
if (limiterData == null)
return;
String[] limiter = limiterData.names;
@ -477,27 +525,31 @@ public class LibraryActivity
// a limiter view was clicked
int i = (Integer)view.getTag();
if (i == 1) {
// generate the artist limiter (we need to query the artist id)
MediaAdapter.Limiter limiter = mSongAdapter.getLimiter();
Assert.assertEquals(MediaUtils.TYPE_ALBUM, limiter.type);
Limiter limiter = mCurrentAdapter.getLimiter();
int type = limiter.type;
if (i == 1 && type == MediaUtils.TYPE_ALBUM) {
ContentResolver resolver = getContentResolver();
Uri uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
String[] projection = new String[] { MediaStore.Audio.Media.ARTIST_ID };
Cursor cursor = resolver.query(uri, projection, limiter.selection, null, null);
Cursor cursor = resolver.query(uri, projection, limiter.data.toString(), null, null);
if (cursor != null) {
if (cursor.moveToNext()) {
setLimiter(mArtistAdapter.getLimiter(cursor.getLong(0)));
updateLimiterViews();
cursor.close();
return;
setLimiter(mArtistAdapter.buildLimiter(cursor.getLong(0)));
}
cursor.close();
}
} else if (i > 0) {
Assert.assertEquals(MediaUtils.TYPE_FILE, limiter.type);
File file = (File)limiter.data;
int diff = limiter.names.length - i;
while (--diff != -1) {
file = file.getParentFile();
}
setLimiter(FileSystemAdapter.buildLimiter(file));
} else {
clearLimiter(type);
}
setLimiter(null);
updateLimiterViews();
} else {
super.onClick(view);
@ -510,12 +562,25 @@ public class LibraryActivity
* @param adapter The adapter that owns the view.
* @param view The MediaView to build from.
*/
private static Intent createClickIntent(MediaAdapter adapter, MediaView view)
private static Intent createClickIntent(LibraryAdapter adapter, MediaView view)
{
int type = adapter.getMediaType();
long id = view.getMediaId();
Intent intent = new Intent();
intent.putExtra("type", adapter.getMediaType());
intent.putExtra("id", view.getMediaId());
intent.putExtra("type", type);
intent.putExtra("id", id);
intent.putExtra("title", view.getTitle());
if (type == MediaUtils.TYPE_FILE) {
File file = (File)adapter.getItem((int)id);
String path;
try {
path = file.getCanonicalPath();
} catch (IOException e) {
path = file.getAbsolutePath();
Log.e("VanillaMusic", "Failed to canonicalize path", e);
}
intent.putExtra("file", path);
}
return intent;
}
@ -523,7 +588,7 @@ public class LibraryActivity
* Builds a media query based off the data stored in the given intent.
*
* @param intent An intent created with
* {@link LibraryActivity#createClickIntent(MediaAdapter,MediaView)}.
* {@link LibraryActivity#createClickIntent(LibraryAdapter,MediaView)}.
* @param empty If true, use the empty projection (only query id).
* @param all If true query all songs in the adapter; otherwise query based
* on the row selected.
@ -540,8 +605,10 @@ public class LibraryActivity
long id = intent.getLongExtra("id", -1);
QueryTask query;
if (all || id == MediaView.HEADER_ID) {
query = mAdapters[type - 1].buildSongQuery(projection);
if (type == MediaUtils.TYPE_FILE) {
query = MediaUtils.buildFileQuery(intent.getStringExtra("file"), projection);
} else if (all || id == MediaView.HEADER_ID) {
query = ((MediaAdapter)mAdapters[type - 1]).buildSongQuery(projection);
query.setExtra(id);
} else {
query = MediaUtils.buildQuery(type, id, projection, null);
@ -570,7 +637,7 @@ public class LibraryActivity
return;
}
MediaAdapter adapter = (MediaAdapter)((ListView)listView).getAdapter();
LibraryAdapter adapter = (LibraryAdapter)((ListView)listView).getAdapter();
MediaView view = (MediaView)((AdapterView.AdapterContextMenuInfo)absInfo).targetView;
// Store view data in intent to avoid problems when the view data changes
@ -596,7 +663,7 @@ public class LibraryActivity
menu.add(0, MENU_EDIT, 0, R.string.edit).setIntent(intent);
}
menu.addSubMenu(0, MENU_ADD_TO_PLAYLIST, 0, R.string.add_to_playlist).getItem().setIntent(intent);
if (adapter != mPlaylistAdapter && adapter != mSongAdapter)
if (view.hasRightBitmap())
menu.add(0, MENU_EXPAND, 0, R.string.expand).setIntent(intent);
if (!isHeader)
menu.add(0, MENU_DELETE, 0, R.string.delete).setIntent(intent);
@ -608,7 +675,7 @@ public class LibraryActivity
*
* @param playlistId The id of the playlist to add to.
* @param intent An intent created with
* {@link LibraryActivity#createClickIntent(MediaAdapter,MediaView)}.
* {@link LibraryActivity#createClickIntent(LibraryAdapter,MediaView)}.
*/
private void addToPlaylist(long playlistId, Intent intent)
{
@ -635,22 +702,33 @@ public class LibraryActivity
* informing the user of this.
*
* @param intent An intent created with
* {@link LibraryActivity#createClickIntent(MediaAdapter,MediaView)}.
* {@link LibraryActivity#createClickIntent(LibraryAdapter,MediaView)}.
*/
private void delete(Intent intent)
{
int type = intent.getIntExtra("type", 1);
long id = intent.getLongExtra("id", -1);
String message = null;
Resources res = getResources();
if (type == MediaUtils.TYPE_PLAYLIST) {
if (type == MediaUtils.TYPE_FILE) {
String file = intent.getStringExtra("file");
boolean success = MediaUtils.deleteFile(new File(file));
if (!success) {
message = res.getString(R.string.delete_file_failed, file);
}
} else if (type == MediaUtils.TYPE_PLAYLIST) {
Playlist.deletePlaylist(getContentResolver(), id);
String message = getResources().getString(R.string.playlist_deleted, intent.getStringExtra("title"));
Toast.makeText(this, message, Toast.LENGTH_SHORT).show();
} else {
int count = PlaybackService.get(this).deleteMedia(type, id);
String message = getResources().getQuantityString(R.plurals.deleted, count, count);
Toast.makeText(this, message, Toast.LENGTH_SHORT).show();
message = res.getQuantityString(R.plurals.deleted, count, count);
}
if (message == null) {
message = res.getString(R.string.deleted, intent.getStringExtra("title"));
}
Toast.makeText(this, message, Toast.LENGTH_SHORT).show();
}
@Override
@ -730,6 +808,13 @@ public class LibraryActivity
return super.onCreateOptionsMenu(menu);
}
@Override
public boolean onPrepareOptionsMenu(Menu menu)
{
menu.findItem(MENU_SORT).setEnabled(mCurrentAdapter != mFilesAdapter);
return super.onPrepareOptionsMenu(menu);
}
@Override
public boolean onOptionsItemSelected(MenuItem item)
{
@ -741,7 +826,7 @@ public class LibraryActivity
openPlaybackActivity();
return true;
case MENU_SORT: {
MediaAdapter adapter = mCurrentAdapter;
MediaAdapter adapter = (MediaAdapter)mCurrentAdapter;
int mode = adapter.getSortMode();
int check;
if (mode < 0) {
@ -780,15 +865,13 @@ public class LibraryActivity
/**
* Hook up a ListView to this Activity and the supplied adapter
*
* @param index Where to put the view and adapter in mLists/mAdapters.
* @param id The id of the ListView
* @param type The media type for the adapter.
* @param adapter The adapter to hook up.
* @param label The text to show on the tab.
* @param icon The icon to show on the tab.
* @param expandable True if the rows are expandable.
* @param hasHeader True if the view should have a header row.
* @param limiter The initial limiter to set on the adapter.
*/
private MediaAdapter setupView(int id, int type, int label, int icon, boolean expandable, boolean hasHeader, MediaAdapter.Limiter limiter)
private void setupView(int index, int id, int label, int icon, LibraryAdapter adapter)
{
ListView view = (ListView)findViewById(id);
view.setOnItemClickListener(this);
@ -797,9 +880,9 @@ public class LibraryActivity
view.setDivider(null);
view.setFastScrollEnabled(true);
MediaAdapter adapter = new MediaAdapter(this, type, expandable, hasHeader, limiter);
view.setAdapter(adapter);
loadSortOrder(adapter);
if (adapter instanceof MediaAdapter)
loadSortOrder((MediaAdapter)adapter);
Resources res = getResources();
String labelRes = res.getString(label);
@ -809,7 +892,9 @@ public class LibraryActivity
else
mTabHost.addTab(mTabHost.newTabSpec(labelRes).setIndicator(labelRes, iconRes).setContent(id));
return adapter;
mAdapters[index] = adapter;
mLists[index] = view;
mRequeryNeeded[index] = true;
}
/**
@ -840,7 +925,7 @@ public class LibraryActivity
*/
private static final int MSG_SAVE_SORT = 16;
/**
* Call {@link LibraryActivity#requestRequery(MediaAdapter)} on the adapter
* Call {@link LibraryActivity#requestRequery(LibraryAdapter)} on the adapter
* passed in obj.
*/
private static final int MSG_REQUEST_REQUERY = 17;
@ -877,16 +962,18 @@ public class LibraryActivity
break;
}
case MSG_RUN_QUERY: {
final MediaAdapter adapter = (MediaAdapter)message.obj;
QueryTask query = adapter.buildQuery();
final Cursor cursor = query.runQuery(getContentResolver());
final LibraryAdapter adapter = (LibraryAdapter)message.obj;
final Object data = adapter.query();
runOnUiThread(new Runnable() {
@Override
public void run()
{
adapter.changeCursor(cursor);
adapter.commitQuery(data);
// scroll to the top of the list
mLists[adapter.getMediaType() - 1].setSelection(0);
}
});
mRequeryNeeded[adapter.getMediaType() - 1] = false;
break;
}
case MSG_SAVE_SORT: {
@ -897,7 +984,7 @@ public class LibraryActivity
break;
}
case MSG_REQUEST_REQUERY:
requestRequery((MediaAdapter)message.obj);
requestRequery((LibraryAdapter)message.obj);
break;
default:
return super.handleMessage(message);
@ -913,15 +1000,15 @@ public class LibraryActivity
*
* Must be called on the UI thread.
*/
public void requestRequery(MediaAdapter adapter)
public void requestRequery(LibraryAdapter adapter)
{
if (adapter == mCurrentAdapter) {
runQuery(adapter);
} else {
adapter.requestRequery();
mRequeryNeeded[adapter.getMediaType() - 1] = true;
// Clear the data for non-visible adapters (so we don't show the old
// data briefly when we later switch to that adapter)
adapter.changeCursor(null);
adapter.clear();
}
}
@ -930,7 +1017,7 @@ public class LibraryActivity
*
* @param adapter The adapter to run the query for.
*/
private void runQuery(MediaAdapter adapter)
private void runQuery(LibraryAdapter adapter)
{
mHandler.removeMessages(MSG_RUN_QUERY, adapter);
mHandler.sendMessage(mHandler.obtainMessage(MSG_RUN_QUERY, adapter));
@ -939,12 +1026,23 @@ public class LibraryActivity
@Override
public void onMediaChange()
{
Handler handler = mUiHandler;
for (MediaAdapter adapter : mAdapters) {
handler.sendMessage(handler.obtainMessage(MSG_REQUEST_REQUERY, adapter));
for (LibraryAdapter adapter : mAdapters) {
postRequestRequery(adapter);
}
}
/**
* Call {@link LibraryActivity#requestRequery(LibraryAdapter)} on the UI
* thread.
*
* @param adapter The adapter, passed to requestRequery.
*/
public void postRequestRequery(LibraryAdapter adapter)
{
Handler handler = mUiHandler;
handler.sendMessage(handler.obtainMessage(MSG_REQUEST_REQUERY, adapter));
}
private void setSearchBoxVisible(boolean visible)
{
mSearchBoxVisible = visible;
@ -1001,10 +1099,11 @@ public class LibraryActivity
@Override
public void onTabChanged(String tag)
{
MediaAdapter adapter = mAdapters[mTabHost.getCurrentTab()];
LibraryAdapter adapter = mAdapters[mTabHost.getCurrentTab()];
mCurrentAdapter = adapter;
if (adapter.isRequeryNeeded())
if (mRequeryNeeded[adapter.getMediaType() - 1]) {
runQuery(adapter);
}
updateLimiterViews();
}
@ -1031,6 +1130,8 @@ public class LibraryActivity
@Override
public void onDismiss(DialogInterface dialog)
{
MediaAdapter adapter = (MediaAdapter)mCurrentAdapter;
ListView list = ((AlertDialog)dialog).getListView();
// subtract 1 for header
int which = list.getCheckedItemPosition() - 1;
@ -1039,7 +1140,6 @@ public class LibraryActivity
if (group.getCheckedRadioButtonId() == R.id.descending)
which = ~which;
MediaAdapter adapter = mCurrentAdapter;
adapter.setSortMode(which);
requestRequery(adapter);

View File

@ -0,0 +1,100 @@
/*
* Copyright (C) 2011 Christopher Eby <kreed@kreed.org>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package org.kreed.vanilla;
import android.widget.ListAdapter;
/**
* Provides support for limiters and a few other methods LibraryActivity uses
* for its adapters.
*/
public interface LibraryAdapter extends ListAdapter {
/**
* Return the type of media represented by this adapter. One of
* MediaUtils.TYPE_*.
*/
public int getMediaType();
/**
* Set the limiter for the adapter.
*
* A limiter is intended to restrict displayed media to only those that are
* children of a given parent media item.
*
* @param limiter The limiter, created by
* {@link LibraryAdapter#buildLimiter(long)}.
*/
public void setLimiter(Limiter limiter);
/**
* Returns the limiter currently active on this adapter or null if none are
* active.
*/
public Limiter getLimiter();
/**
* Builds a limiter based off of the media represented by the given row.
*
* @param id The id of the row.
* @see LibraryAdapter#getLimiter()
* @see LibraryAdapter#setLimiter(Limiter)
*/
public Limiter buildLimiter(long id);
/**
* Set a new filter.
*
* The data should be requeried after calling this.
*
* @param filter The terms to filter on, separated by spaces. Only
* media that contain all of the terms (in any order) will be displayed
* after filtering is complete.
*/
public void setFilter(String filter);
/**
* Retrieve the data for this adapter. The data must be set with
* {@link LibraryAdapter#commitQuery(Object)} before it takes effect.
*
* This should be called on a worker thread.
*
* @return The data. Contents depend on the sub-class.
*/
public Object query();
/**
* Update the adapter with the given data.
*
* Must be called on the UI thread.
*
* @param data Data from {@link LibraryAdapter#query()}.
*/
public void commitQuery(Object data);
/**
* Clear the data for this adapter.
*
* Must be called on the UI thread.
*/
public void clear();
}

View File

@ -0,0 +1,64 @@
/*
* Copyright (C) 2011 Christopher Eby <kreed@kreed.org>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package org.kreed.vanilla;
import java.io.Serializable;
/**
* Limiter is a constraint for MediaAdapter and FileSystemAdapter used when
* a row is "expanded".
*/
public class Limiter implements Serializable {
private static final long serialVersionUID = -4729694243900202614L;
/**
* The type of the limiter. One of MediaUtils.TYPE_ARTIST, TYPE_ALBUM,
* TYPE_GENRE, or TYPE_FILE.
*/
public final int type;
/**
* Each element will be given a separate view each representing a higher
* different limiters. The first element is the broadest limiter, the last
* the most specific. For example, an album limiter would look like:
* { "Some Artist", "Some Album" }
* Or a file limiter:
* { "sdcard", "Music", "folder" }
*/
public final String[] names;
/**
* The data for the limiter. This varies according to the type of the
* limiter.
*/
public final Object data;
/**
* Create a limiter with the given data. All parameters initialize their
* corresponding fields in the class.
*/
public Limiter(int type, String[] names, Object data)
{
this.type = type;
this.names = names;
this.data = data;
}
}

View File

@ -32,7 +32,6 @@ import android.view.View;
import android.view.ViewGroup;
import android.widget.CursorAdapter;
import android.widget.SectionIndexer;
import java.io.Serializable;
import java.util.regex.Pattern;
/**
@ -46,7 +45,7 @@ import java.util.regex.Pattern;
* to a specific group to be displayed, e.g. only songs from a certain artist.
* See getLimiter and setLimiter for details.
*/
public class MediaAdapter extends CursorAdapter implements SectionIndexer {
public class MediaAdapter extends CursorAdapter implements SectionIndexer, LibraryAdapter {
private static final Pattern SPACE_SPLIT = Pattern.compile("\\s+");
/**
@ -109,10 +108,6 @@ public class MediaAdapter extends CursorAdapter implements SectionIndexer {
* The sort order for use with buildSongQuery().
*/
private String mSongSort;
/**
* True if the data is stale and the query should be re-run.
*/
private boolean mNeedsRequery;
/**
* The human-readable descriptions for each sort mode.
*/
@ -152,7 +147,6 @@ public class MediaAdapter extends CursorAdapter implements SectionIndexer {
mHasHeader = hasHeader;
mLimiter = limiter;
mIndexer = new MusicAlphabetIndexer(1);
mNeedsRequery = true;
switch (type) {
case MediaUtils.TYPE_ARTIST:
@ -272,31 +266,7 @@ public class MediaAdapter extends CursorAdapter implements SectionIndexer {
notifyDataSetChanged();
}
/**
* Returns true if the data is stale and should be requeried.
*/
public boolean isRequeryNeeded()
{
return mNeedsRequery;
}
/**
* Mark the current data as requiring a requery.
*/
public void requestRequery()
{
mNeedsRequery = true;
}
/**
* Set a new filter.
*
* The data should be requeried after calling this.
*
* @param filter The terms to filter on, separated by spaces. Only
* media that contain all of the terms (in any order) will be displayed
* after filtering is complete.
*/
@Override
public void setFilter(String filter)
{
mConstraint = filter;
@ -373,26 +343,28 @@ public class MediaAdapter extends CursorAdapter implements SectionIndexer {
if (limiter != null && limiter.type == MediaUtils.TYPE_GENRE) {
// Genre is not standard metadata for MediaStore.Audio.Media.
// We have to query it through a separate provider. : /
return MediaUtils.buildGenreQuery(limiter.id, projection, selection.toString(), selectionArgs, sort);
return MediaUtils.buildGenreQuery((Long)limiter.data, projection, selection.toString(), selectionArgs, sort);
} else {
if (limiter != null) {
if (selection.length() != 0)
selection.append(" AND ");
selection.append(limiter.selection);
selection.append(limiter.data);
}
return new QueryTask(mStore, projection, selection.toString(), selectionArgs, sort);
}
}
/**
* Build a query to populate the adapter with. The result should be set with
* changeCursor().
*/
public QueryTask buildQuery()
@Override
public Object query()
{
mNeedsRequery = false;
return buildQuery(mProjection, false);
return buildQuery(mProjection, false).runQuery(mContext.getContentResolver());
}
@Override
public void commitQuery(Object data)
{
changeCursor((Cursor)data);
}
/**
@ -413,50 +385,35 @@ public class MediaAdapter extends CursorAdapter implements SectionIndexer {
return query;
}
/**
* Return the type of media represented by this adapter. One of
* MediaUtils.TYPE_*.
*/
@Override
public void clear()
{
changeCursor(null);
}
@Override
public int getMediaType()
{
return mType;
}
/**
* Set the limiter for the adapter.
*
* A limiter is intended to restrict displayed media to only those that are
* children of a given parent media item.
*
* The data should be requeried after calling this.
*
* @param limiter The limiter, created by {@link MediaAdapter#getLimiter(long)}.
*/
public final void setLimiter(Limiter limiter)
@Override
public void setLimiter(Limiter limiter)
{
mLimiter = limiter;
}
/**
* Returns the limiter currently active on this adapter or null if none are
* active.
*/
public final Limiter getLimiter()
@Override
public Limiter getLimiter()
{
return mLimiter;
}
/**
* Builds a limiter based off of the media represented by the given row.
*
* @param id The id of the row.
* @see MediaAdapter#getLimiter()
* @see MediaAdapter#setLimiter(MediaAdapter.Limiter)
*/
public Limiter getLimiter(long id)
@Override
public Limiter buildLimiter(long id)
{
String[] fields;
String selection = null;
Object data;
Cursor cursor = getCursor();
if (cursor == null)
@ -468,26 +425,23 @@ public class MediaAdapter extends CursorAdapter implements SectionIndexer {
}
switch (mType) {
case MediaUtils.TYPE_ARTIST: {
case MediaUtils.TYPE_ARTIST:
fields = new String[] { cursor.getString(1) };
String field = MediaStore.Audio.Media.ARTIST_ID;
selection = String.format("%s=%d", field, id);
data = String.format("%s=%d", MediaStore.Audio.Media.ARTIST_ID, id);
break;
}
case MediaUtils.TYPE_ALBUM: {
case MediaUtils.TYPE_ALBUM:
fields = new String[] { cursor.getString(2), cursor.getString(1) };
String field = MediaStore.Audio.Media.ALBUM_ID;
selection = String.format("%s=%d", field, id);
data = String.format("%s=%d", MediaStore.Audio.Media.ALBUM_ID, id);
break;
}
case MediaUtils.TYPE_GENRE:
fields = new String[] { cursor.getString(1) };
data = id;
break;
default:
throw new IllegalStateException("getLimiter() is not supported for media type: " + mType);
}
return new MediaAdapter.Limiter(id, mType, selection, fields);
return new Limiter(mType, fields, data);
}
@Override
@ -598,24 +552,4 @@ public class MediaAdapter extends CursorAdapter implements SectionIndexer {
{
return mSortMode;
}
/**
* Limiter is a constraint for MediaAdapters used when a row is "expanded".
*/
public static class Limiter implements Serializable {
private static final long serialVersionUID = -4729694243900202614L;
public final String[] names;
public final long id;
public final int type;
public final String selection;
public Limiter(long id, int type, String selection, String[] names)
{
this.type = type;
this.names = names;
this.id = id;
this.selection = selection;
}
}
}

View File

@ -24,14 +24,19 @@ package org.kreed.vanilla;
import android.content.ContentResolver;
import android.database.Cursor;
import android.database.DatabaseUtils;
import android.net.Uri;
import android.provider.MediaStore;
import java.io.File;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Random;
import junit.framework.Assert;
/**
* Provides some static Song/MediaStore-related utility functions.
*/
public class MediaUtils {
/**
* A special invalid media type.
@ -57,6 +62,11 @@ public class MediaUtils {
* Type indicating ids represent genres.
*/
public static final int TYPE_GENRE = 5;
/**
* Special type for files and folders. Most methods do not accept this type
* since files have no MediaStore id and require special handling.
*/
public static final int TYPE_FILE = 6;
/**
* The default sort order for media queries. First artist, then album, then
@ -488,4 +498,42 @@ public class MediaUtils {
return result;
}
/**
* Delete the given file or directory recursively.
*
* @return True if successful; false otherwise.
*/
public static boolean deleteFile(File file)
{
File[] children = file.listFiles();
if (children != null) {
for (File child : children) {
deleteFile(child);
}
}
return file.delete();
}
/**
* Build a query that will contain all the media under the given path.
*
* @param path The path, e.g. /mnt/sdcard/music/
* @param projection The columns to query
* @return The initialized query.
*/
public static QueryTask buildFileQuery(String path, String[] projection)
{
// It would be better to use selectionArgs to pass path here, but there
// doesn't appear to be any way to pass the * when using it.
StringBuilder selection = new StringBuilder();
selection.append("_data GLOB ");
DatabaseUtils.appendEscapedSQLString(selection, path);
// delete the quotation mark added by the escape method
selection.deleteCharAt(selection.length() - 1);
selection.append("*' AND is_music!=0");
Uri media = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
return new QueryTask(media, projection, selection.toString(), null, DEFAULT_SORT);
}
}

View File

@ -265,6 +265,14 @@ public final class MediaView extends View {
return mTitle;
}
/**
* Returns true if the view has a right bitmap that is visible.
*/
public boolean hasRightBitmap()
{
return mRightBitmap != null && mShowBitmaps;
}
/**
* Returns true if the right bitmap was pressed in the last touch event.
*/
@ -305,6 +313,19 @@ public final class MediaView extends View {
invalidate();
}
/**
* Set the id and title in this view.
*
* @param id The new id.
* @param title The new title for the view.
*/
public void setData(long id, String title)
{
mId = id;
mTitle = title;
invalidate();
}
/**
* Update mExpanderPressed.
*/