Playlist editor

This commit is contained in:
Christopher Eby 2011-10-01 02:02:43 -05:00
parent 712eee13b5
commit 99b92f7e14
19 changed files with 1009 additions and 94 deletions

View File

@ -45,6 +45,10 @@ THE SOFTWARE.
android:name="LibraryActivity"
android:theme="@style/NoTitle"
android:launchMode="singleTop" />
<activity
android:name="PlaylistActivity"
android:launchMode="singleTask"
android:theme="@style/Black" />
<activity
android:name="MiniPlaybackActivity"
android:theme="@style/Dialog"

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 550 B

View File

@ -110,7 +110,8 @@ THE SOFTWARE.
<FrameLayout
android:id="@+id/lists"
android:layout_width="fill_parent"
android:layout_height="fill_parent">
android:layout_height="0px"
android:layout_weight="1">
<ListView
android:id="@+id/artist_list"
android:visibility="gone"

View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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.
-->
<merge xmlns:android="http://schemas.android.com/apk/res/android">
<org.kreed.vanilla.DragListView
android:id="@+id/playlist"
android:layout_width="fill_parent"
android:layout_height="fill_parent" />
</merge>

View File

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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.
-->
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="fill_parent"
android:layout_height="fill_parent"
android:padding="3dip"
android:orientation="horizontal">
<Button
android:id="@+id/edit"
android:layout_width="0px"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/edit" />
<Button
android:id="@+id/delete"
android:layout_width="0px"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/delete" />
</LinearLayout>

View File

@ -21,8 +21,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
-->
<resources>
<style name="Dialog" parent="android:style/Theme.DeviceDefault.Dialog" />
<style name="NoTitle" parent="android:style/Theme.DeviceDefault">
<item name="android:windowNoTitle">true</item>
</style>
<style name="Dialog" parent="android:Theme.DeviceDefault.Dialog" />
<style name="Theme" parent="android:Theme.DeviceDefault" />
</resources>

View File

@ -56,6 +56,7 @@ THE SOFTWARE.
<string name="delete">Delete</string>
<string name="playback_view">Now Playing</string>
<string name="search">Search</string>
<string name="done">Done</string>
<plurals name="playing">
<item quantity="one">1 song playing.</item>

View File

@ -21,11 +21,17 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
-->
<resources>
<style name="Dialog" parent="android:style/Theme.Dialog" />
<style name="NoTitle" parent="android:style/Theme">
<style name="Dialog" parent="android:Theme.Dialog" />
<style name="Theme" parent="android:Theme" />
<style name="Black" parent="Theme">
<item name="android:colorBackground">@android:color/black</item>
<item name="android:windowBackground">@android:color/black</item>
</style>
<style name="NoTitle" parent="Theme">
<item name="android:windowNoTitle">true</item>
</style>
<style name="NoBackground" parent="style/NoTitle">
<style name="NoBackground" parent="Theme">
<item name="android:windowNoTitle">true</item>
<item name="android:windowBackground">@null</item>
</style>
<string-array name="entry_values">

View File

@ -0,0 +1,369 @@
/*
* Copyright (C) 2008 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.kreed.vanilla;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.PixelFormat;
import android.os.Message;
import android.util.AttributeSet;
import android.view.Gravity;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.widget.AdapterView;
import android.widget.ImageView;
import android.widget.ListView;
import android.os.Handler;
/**
* A ListView that supports dragging to reorder its elements.
*
* This implementation has some restrictions:
* Footers are unsupported
* All non-header views must be MediaViews of uniform height
* The adapter must be a PlaylistAdapter
*
* Dragging disabled by default. Enable it with
* {@link DragListView#setEditable(boolean)}.
*
* This should really be built-in to Android. This implementation is SUPER-
* HACKY. : /
*/
public class DragListView extends ListView implements Handler.Callback {
private static final int MSG_SCROLL = 0;
private final Handler mHandler = new Handler(this);
private PlaylistAdapter mAdapter;
/**
* True to allow dragging; false otherwise.
*/
private boolean mEditable;
private ImageView mDragView;
private Bitmap mDragBitmap;
private WindowManager mWindowManager;
private WindowManager.LayoutParams mWindowParams;
/**
* At which position is the item currently being dragged. Note that this
* takes in to account header items.
*/
private int mDragPos;
/**
* At which position was the item being dragged originally
*/
private int mSrcDragPos;
/**
* At what y offset inside the dragged view did the user grab it.
*/
private int mDragPointY;
/**
* The difference between screen coordinates and coordinates in the drag
* view.
*/
private int mYOffset;
/**
* The height of the view being dragged.
*/
private int mDragHeight;
/**
* The y coordinate of the top of the drag view after the last motion
* event.
*/
private int mLastMotionY;
public DragListView(Context context, AttributeSet attrs)
{
super(context, attrs);
}
public void setAdapter(PlaylistAdapter adapter)
{
super.setAdapter(adapter);
// Keep track of adapter here since getAdapter() will return a wrapper
// when there are headers.
mAdapter = adapter;
}
/**
* Set whether to allow elements to be reordered.
*
* @param editable True to allow reordering.
*/
public void setEditable(boolean editable)
{
mEditable = editable;
if (!editable)
stopDragging();
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev)
{
if (mEditable) {
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
stopDragging();
int x = (int)ev.getX();
// The left half of the item is the grabber for dragging the item
if (x < getWidth() / 4) {
int item = pointToPosition(x, (int)ev.getY());
if (item != AdapterView.INVALID_POSITION && item >= getHeaderViewsCount()) {
startDragging(item, ev);
return false;
}
}
break;
}
}
return super.onInterceptTouchEvent(ev);
}
@Override
public boolean onTouchEvent(MotionEvent ev)
{
if (!mEditable || mDragView == null)
return super.onTouchEvent(ev);
switch (ev.getAction()) {
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
stopDragging();
int offset = getHeaderViewsCount();
if (mDragPos >= offset && mDragPos < getCount())
mAdapter.move(mSrcDragPos - offset, mDragPos - offset);
break;
case MotionEvent.ACTION_MOVE:
int y = (int)ev.getY() - mDragPointY;
mLastMotionY = y;
mWindowParams.x = 0;
mWindowParams.y = y + mYOffset;
mWindowManager.updateViewLayout(mDragView, mWindowParams);
computeDragPosition(y);
break;
}
return true;
}
/**
* Restore size and visibility for all list items
*/
private void unExpandViews()
{
for (int i = 0, count = getChildCount(); i != count; ++i) {
View view = getChildAt(i);
ViewGroup.LayoutParams params = view.getLayoutParams();
params.height = 0;
view.setLayoutParams(params);
view.setVisibility(View.VISIBLE);
}
}
/**
* Adjust visibility and size to make it appear as though
* an item is being dragged around and other items are making
* room for it.
*
* If dropping the item would result in it still being in the
* same place, then make the dragged list item's size normal,
* but make the item invisible.
* Otherwise, if the dragged list item is still on screen, make
* it as small as possible and expand the item below the insert
* point.
*/
private void doExpansion()
{
int firstVisibile = getFirstVisiblePosition();
int childNum = mDragPos - firstVisibile;
if (mDragPos > mSrcDragPos)
childNum += 1;
int headerCount = getHeaderViewsCount();
int childCount = getChildCount();
View dragSrcView = getChildAt(mSrcDragPos - firstVisibile);
int start = firstVisibile < headerCount ? headerCount - firstVisibile : 0;
for (int i = start; i != childCount; ++i) {
MediaView view = (MediaView)getChildAt(i);
int height = 0;
int visibility = View.VISIBLE;
boolean gravity = true;
if (view == dragSrcView) {
if (mDragPos == mSrcDragPos) {
// hovering over the original location: show empty space
visibility = View.INVISIBLE;
} else {
// not hovering over it: show nothing
// Ideally the item would be completely gone, but neither
// setting its size to 0 nor settings visibility to GONE
// has the desired effect.
height = 1;
}
} else if (i == childNum) {
// hovering over this row; expand it to "make room" for the
// dragged item
height = view.getPreferredHeight() * 2;
} else if (childNum == childCount && i == childCount - 1) {
// hovering over the bottom of the list: we need to show the
// expanded area at the bottom of the view, so set the gravity
// to top
height = view.getPreferredHeight() * 2;
gravity = false;
}
view.setBottomGravity(gravity);
view.setVisibility(visibility);
ViewGroup.LayoutParams params = view.getLayoutParams();
params.height = height;
view.setLayoutParams(params);
}
}
/**
* Computes the drag position based on where the drag view is hovering.
* Expands views and updates scrolling when this position changes.
*
* @param y The y coordinate of the top of the drag view.
* @return The scrolling speed in pixels
*/
private int computeDragPosition(int y)
{
// This assumes uniform height for all non-header rows
int firstVisible = getFirstVisiblePosition();
int topPos = Math.max(getHeaderViewsCount(), firstVisible);
int dragHeight = mDragHeight;
View view = getChildAt(topPos - firstVisible);
int viewMiddle = view.getTop() + dragHeight / 2;
int dragPos = Math.min(getCount() - 1, topPos + Math.max(0, y - viewMiddle + dragHeight) / dragHeight);
if (dragPos != mDragPos) {
mDragPos = dragPos;
doExpansion();
}
int height = getHeight();
int upperBound = height / 4;
int lowerBound = height * 3 / 4;
if (y > lowerBound && (getLastVisiblePosition() < getCount() - 1 || getChildAt(getChildCount() - 1).getBottom() > getBottom()))
return y > (height + lowerBound) / 2 ? 16 : 4;
else if (y < upperBound && (getFirstVisiblePosition() != 0 || getChildAt(0).getTop() < 0))
return y < upperBound / 2 ? -16 : -4;
return 0;
}
/**
* Start a drag operation.
*
* @param row The row number of the item to drag
* @param ev The touch event that started this drag.
*/
private void startDragging(int row, MotionEvent ev)
{
int y = (int)ev.getY();
View item = getChildAt(row - getFirstVisiblePosition());
mDragPointY = y - item.getTop();
mYOffset = (int)ev.getRawY() - y;
mWindowParams = new WindowManager.LayoutParams();
mWindowParams.gravity = Gravity.TOP | Gravity.LEFT;
mWindowParams.x = 0;
mWindowParams.y = y - mDragPointY + mYOffset;
mWindowParams.height = WindowManager.LayoutParams.WRAP_CONTENT;
mWindowParams.width = WindowManager.LayoutParams.WRAP_CONTENT;
mWindowParams.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
| WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE
| WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
| WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN
| WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS;
mWindowParams.format = PixelFormat.TRANSLUCENT;
mWindowParams.windowAnimations = 0;
item.buildDrawingCache();
// Create a copy of the drawing cache so that it does not get recycled
// by the framework when the list tries to clean up memory
Bitmap bitmap = Bitmap.createBitmap(item.getDrawingCache());
item.destroyDrawingCache();
mDragBitmap = bitmap;
Context context = getContext();
ImageView view = new ImageView(context);
view.setPadding(0, 0, 0, 0);
view.setImageBitmap(bitmap);
mWindowManager = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE);
mWindowManager.addView(view, mWindowParams);
mDragView = view;
mDragHeight = bitmap.getHeight();
mSrcDragPos = row;
// Force expansion on next motion event
mDragPos = INVALID_POSITION;
mHandler.sendEmptyMessageDelayed(MSG_SCROLL, 50);
}
/**
* Stop a drag operation.
*/
private void stopDragging()
{
if (mDragView != null) {
mDragView.setVisibility(GONE);
mWindowManager.removeView(mDragView);
mDragView.setImageDrawable(null);
mDragView = null;
}
if (mDragBitmap != null) {
mDragBitmap.recycle();
mDragBitmap = null;
}
unExpandViews();
mHandler.removeMessages(MSG_SCROLL);
}
@Override
public boolean handleMessage(Message message)
{
if (message.what == MSG_SCROLL) {
if (mDragPos != INVALID_POSITION) {
int speed = computeDragPosition(mLastMotionY);
if (speed != 0) {
View view = getChildAt(0);
if (view != null) {
int pos = view.getTop();
setSelectionFromTop(getFirstVisiblePosition(), pos - speed);
}
}
}
mHandler.sendEmptyMessageDelayed(MSG_SCROLL, 50);
}
return true;
}
}

View File

@ -140,7 +140,7 @@ public class LibraryActivity extends PlaybackActivity implements AdapterView.OnI
mArtistAdapter = setupView(R.id.artist_list, MediaUtils.TYPE_ARTIST, true, true, null);
mAlbumAdapter = setupView(R.id.album_list, MediaUtils.TYPE_ALBUM, true, true, state == null ? null : (MediaAdapter.Limiter)state.getSerializable("limiter_albums"));
mSongAdapter = setupView(R.id.song_list, MediaUtils.TYPE_SONG, false, true, state == null ? null : (MediaAdapter.Limiter)state.getSerializable("limiter_songs"));
mPlaylistAdapter = setupView(R.id.playlist_list, MediaUtils.TYPE_PLAYLIST, false, false, null);
mPlaylistAdapter = setupView(R.id.playlist_list, MediaUtils.TYPE_PLAYLIST, true, false, null);
mGenreAdapter = setupView(R.id.genre_list, MediaUtils.TYPE_GENRE, 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 };
@ -267,7 +267,7 @@ public class LibraryActivity extends PlaybackActivity implements AdapterView.OnI
int mode = modeForAction[action];
QueryTask query = buildQueryFromIntent(intent, false);
PlaybackService.get(this).addSongs(mode, query);
PlaybackService.get(this).addSongs(mode, query, 0);
mLastActedId = intent.getLongExtra("id", -1);
@ -334,12 +334,17 @@ public class LibraryActivity extends PlaybackActivity implements AdapterView.OnI
public void onItemClick(AdapterView<?> list, View view, int pos, long id)
{
MediaView mediaView = (MediaView)view;
if (mediaView.isExpanderPressed())
expand(createClickIntent((MediaAdapter)list.getAdapter(), mediaView));
else if (id == mLastActedId)
MediaAdapter adapter = (MediaAdapter)list.getAdapter();
if (mediaView.isRightBitmapPressed()) {
if (adapter == mPlaylistAdapter)
editPlaylist(mediaView.getMediaId(), mediaView.getTitle());
else
expand(createClickIntent(adapter, mediaView));
} else if (id == mLastActedId) {
startActivity(new Intent(this, FullPlaybackActivity.class));
else
pickSongs(createClickIntent((MediaAdapter)list.getAdapter(), mediaView), mDefaultAction);
} else {
pickSongs(createClickIntent(adapter, mediaView), mDefaultAction);
}
}
public void afterTextChanged(Editable editable)
@ -450,7 +455,6 @@ public class LibraryActivity extends PlaybackActivity implements AdapterView.OnI
Intent intent = new Intent();
intent.putExtra("type", adapter.getMediaType());
intent.putExtra("id", view.getMediaId());
intent.putExtra("isHeader", view.isHeader());
intent.putExtra("title", view.getTitle());
return intent;
}
@ -472,13 +476,12 @@ public class LibraryActivity extends PlaybackActivity implements AdapterView.OnI
else
projection = empty ? Song.EMPTY_PROJECTION : Song.FILLED_PROJECTION;
long id = intent.getLongExtra("id", -1);
QueryTask query;
if (intent.getBooleanExtra("isHeader", false)) {
if (id == MediaView.HEADER_ID)
query = mAdapters[type - 1].buildSongQuery(projection);
} else {
long id = intent.getLongExtra("id", -1);
else
query = MediaUtils.buildQuery(type, id, projection, null);
}
return query;
}
@ -503,7 +506,9 @@ public class LibraryActivity extends PlaybackActivity implements AdapterView.OnI
// as worked is performed in the background.
Intent intent = createClickIntent(adapter, view);
if (view.isHeader())
boolean isHeader = view.getMediaId() == MediaView.HEADER_ID;
if (isHeader)
menu.setHeaderTitle(getString(R.string.all_songs));
else
menu.setHeaderTitle(view.getTitle());
@ -515,9 +520,9 @@ public class LibraryActivity extends PlaybackActivity implements AdapterView.OnI
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 (view.hasExpanders())
if (adapter != mPlaylistAdapter && adapter != mSongAdapter)
menu.add(0, MENU_EXPAND, 0, R.string.expand).setIntent(intent);
if (!view.isHeader())
if (!isHeader)
menu.add(0, MENU_DELETE, 0, R.string.delete).setIntent(intent);
}
@ -538,6 +543,17 @@ public class LibraryActivity extends PlaybackActivity implements AdapterView.OnI
Toast.makeText(this, message, Toast.LENGTH_SHORT).show();
}
/**
* Open the playlist editor for the playlist with the given id.
*/
private void editPlaylist(long playlistId, String title)
{
Intent launch = new Intent(this, PlaylistActivity.class);
launch.putExtra("playlist", playlistId);
launch.putExtra("title", title);
startActivity(launch);
}
/**
* Delete the media represented by the given intent and show a Toast
* informing the user of this.
@ -609,13 +625,9 @@ public class LibraryActivity extends PlaybackActivity implements AdapterView.OnI
}
break;
}
case MENU_EDIT: {
Intent launch = new Intent(Intent.ACTION_EDIT);
launch.setDataAndType(Uri.EMPTY, "vnd.android.cursor.dir/track");
launch.putExtra("playlist", String.valueOf(intent.getLongExtra("id", 0)));
startActivity(launch);
case MENU_EDIT:
editPlaylist(intent.getLongExtra("id", 0), intent.getStringExtra("title"));
break;
}
case MENU_SELECT_PLAYLIST:
mHandler.sendMessage(mHandler.obtainMessage(MSG_ADD_TO_PLAYLIST, intent));
break;

View File

@ -223,7 +223,7 @@ public class MediaAdapter extends CursorAdapter implements SectionIndexer {
if (pos == 0) {
MediaView view;
if (convertView == null)
view = new MediaView(mContext, mExpandable);
view = new MediaView(mContext, null, mExpandable ? MediaView.sExpander : null);
else
view = (MediaView)convertView;
view.makeHeader(mHeaderText);
@ -508,7 +508,7 @@ public class MediaAdapter extends CursorAdapter implements SectionIndexer {
@Override
public View newView(Context context, Cursor cursor, ViewGroup parent)
{
return new MediaView(context, mExpandable);
return new MediaView(mContext, null, mExpandable ? MediaView.sExpander : null);
}
/**

View File

@ -42,10 +42,16 @@ import android.view.View;
* to the right side.
*/
public final class MediaView extends View {
/**
* Views that have been made into a header by
* {@link MediaView#makeHeader(String)} will be given this id.
*/
public static final long HEADER_ID = -1;
/**
* The expander arrow bitmap used in all views that have expanders.
*/
private static Bitmap sExpander;
public static Bitmap sExpander;
/**
* The paint object, cached for reuse.
*/
@ -76,6 +82,19 @@ public final class MediaView extends View {
sPaint.setAntiAlias(true);
}
/**
* The cached measured view height.
*/
private final int mViewHeight;
/**
* An optional bitmap to display on the left of the view.
*/
private final Bitmap mLeftBitmap;
/**
* An optional bitmap to display on the right of the view.
*/
private final Bitmap mRightBitmap;
/**
* The MediaStore id of the media represented by this view.
*/
@ -89,37 +108,47 @@ public final class MediaView extends View {
*/
private String mSubTitle;
/**
* True if the last touch event was over the expander arrow.
* True to show the bitmaps, false to hide them. Defaults to true.
*/
private boolean mExpanderPressed;
private boolean mShowBitmaps = true;
private boolean mBottomGravity;
/**
* True if the expander should be shown.
* The x coordinate of the last touch event.
*/
private final boolean mExpandable;
/**
* The cached measured view height.
*/
private int mViewHeight;
/**
* True if this view is a header. Will override expandable setting to false.
*/
private boolean mIsHeader;
private int mTouchX;
/**
* Construct a MediaView.
*
* @param context A Context to use.
* @param expandable True if the expander should be shown.
* @param leftBitmap An optional bitmap to be shown in the left side of
* the view.
* @param rightBitmap An optional bitmap to be shown in the right side of
* the view.
*/
public MediaView(Context context, boolean expandable)
public MediaView(Context context, Bitmap leftBitmap, Bitmap rightBitmap)
{
super(context);
mLeftBitmap = leftBitmap;
mRightBitmap = rightBitmap;
mExpandable = expandable;
int height = 7 * sTextSize / 2;
if (mLeftBitmap != null)
height = Math.max(height, mLeftBitmap.getHeight() + sTextSize);
if (mRightBitmap != null)
height = Math.max(height, mRightBitmap.getHeight() + sTextSize);
mViewHeight = height;
}
mViewHeight = 7 * sTextSize / 2;
if (expandable)
mViewHeight = Math.max(mViewHeight, sExpander.getHeight() + sTextSize);
/**
* Set whether to show the left and right bitmaps. By default, will show them.
*
* @param show If false, do not show the bitmaps.
*/
public void setShowBitmaps(boolean show)
{
mShowBitmaps = show;
}
/**
@ -128,7 +157,10 @@ public final class MediaView extends View {
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)
{
setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), mViewHeight);
if (MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.EXACTLY)
setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), MeasureSpec.getSize(heightMeasureSpec));
else
setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), mViewHeight);
}
/**
@ -141,13 +173,17 @@ public final class MediaView extends View {
return;
int width = getWidth();
int height = getHeight();
int height = mViewHeight;
int padding = sTextSize / 2;
int xOffset = 0;
if (mBottomGravity)
canvas.translate(0, getHeight() - mViewHeight);
Paint paint = sPaint;
if (mExpandable && !mIsHeader) {
Bitmap expander = sExpander;
if (mShowBitmaps && mRightBitmap != null) {
Bitmap expander = mRightBitmap;
width -= padding * 4 + expander.getWidth();
paint.setColor(Color.GRAY);
@ -158,6 +194,12 @@ public final class MediaView extends View {
canvas.drawBitmap(expander, width + padding * 2, (height - expander.getHeight()) / 2, paint);
}
if (mShowBitmaps && mLeftBitmap != null) {
Bitmap expander = mLeftBitmap;
canvas.drawBitmap(expander, 0, (height - expander.getHeight()) / 2, paint);
xOffset = expander.getWidth();
}
canvas.save();
canvas.clipRect(padding, 0, width - padding, height);
@ -167,13 +209,13 @@ public final class MediaView extends View {
allocatedHeight = height / 2 - padding * 3 / 2;
paint.setColor(Color.GRAY);
canvas.drawText(mSubTitle, padding, height / 2 + padding / 2 + (allocatedHeight - sTextSize) / 2 - paint.ascent(), paint);
canvas.drawText(mSubTitle, xOffset + padding, height / 2 + padding / 2 + (allocatedHeight - sTextSize) / 2 - paint.ascent(), paint);
} else {
allocatedHeight = height - padding * 2;
}
paint.setColor(Color.WHITE);
canvas.drawText(mTitle, padding, padding + (allocatedHeight - sTextSize) / 2 - paint.ascent(), paint);
canvas.drawText(mTitle, xOffset + padding, padding + (allocatedHeight - sTextSize) / 2 - paint.ascent(), paint);
canvas.restore();
width = getWidth();
@ -186,6 +228,27 @@ public final class MediaView extends View {
paint.setShader(null);
}
/**
* Set the gravity of the view (top or bottom), determing which edge to
* align the content to.
*
* @param bottom True for bottom gravity; false for top gravity.
*/
public void setBottomGravity(boolean bottom)
{
mBottomGravity = bottom;
}
/**
* Returns the desired height for this view.
*
* @return The measured view height.
*/
public int getPreferredHeight()
{
return mViewHeight;
}
/**
* Returns the MediaStore id of the media represented by this view.
*/
@ -203,20 +266,11 @@ public final class MediaView extends View {
}
/**
* Returns true if the expander arrow was pressed in the last touch
* event.
* Returns true if the right bitmap was pressed in the last touch event.
*/
public boolean isExpanderPressed()
public boolean isRightBitmapPressed()
{
return mExpanderPressed;
}
/**
* Returns true if views has expander arrows displayed.
*/
public boolean hasExpanders()
{
return mExpandable;
return mRightBitmap != null && mShowBitmaps && mTouchX > getWidth() - mRightBitmap.getWidth() - 2 * sTextSize;
}
/**
@ -226,7 +280,8 @@ public final class MediaView extends View {
*/
public void makeHeader(String text)
{
mIsHeader = true;
mShowBitmaps = false;
mId = HEADER_ID;
mTitle = text;
mSubTitle = null;
}
@ -242,7 +297,7 @@ public final class MediaView extends View {
*/
public void updateMedia(Cursor cursor, boolean useSecondary)
{
mIsHeader = false;
mShowBitmaps = true;
mId = cursor.getLong(0);
mTitle = cursor.getString(1);
if (useSecondary)
@ -250,21 +305,13 @@ public final class MediaView extends View {
invalidate();
}
/**
* Returns true if the view is set to be a "Play/Enqueue All" header.
*/
public boolean isHeader()
{
return mIsHeader;
}
/**
* Update mExpanderPressed.
*/
@Override
public boolean onTouchEvent(MotionEvent event)
{
mExpanderPressed = mExpandable && !mIsHeader && event.getX() > getWidth() - sExpander.getWidth() - 2 * sTextSize;
mTouchX = (int)event.getX();
return false;
}
}

View File

@ -955,7 +955,7 @@ public final class PlaybackService extends Service implements Handler.Callback,
processSong((Song)message.obj);
break;
case QUERY:
runQuery(message.arg1, (QueryTask)message.obj);
runQuery(message.arg1, (QueryTask)message.obj, message.arg2);
break;
case POST_CREATE:
mHeadsetPause = getSettings(this).getBoolean("headset_pause", true);
@ -1125,15 +1125,22 @@ public final class PlaybackService extends Service implements Handler.Callback,
/**
* Run the query and add the results to the timeline. Should be called in the
* worker thread.
*
* @param mode How to add songs. Passed to
* {@link SongTimeline#addSongs(int, android.database.Cursor, int)}
* @param query The query to run.
* @param jumpTo Passed to
* {@link SongTimeline#addSongs(int, android.database.Cursor, int)}
*/
public void runQuery(int mode, QueryTask query)
public void runQuery(int mode, QueryTask query, int jumpTo)
{
int count = mTimeline.addSongs(mode, query.runQuery(getContentResolver()));
int count = mTimeline.addSongs(mode, query.runQuery(getContentResolver()), jumpTo);
int text;
switch (mode) {
case SongTimeline.MODE_PLAY:
case SongTimeline.MODE_PLAY_JUMP_TO:
text = R.plurals.playing;
break;
case SongTimeline.MODE_PLAY_NEXT:
@ -1144,7 +1151,7 @@ public final class PlaybackService extends Service implements Handler.Callback,
return;
}
if (mode == SongTimeline.MODE_PLAY && count != 0 && (mState & FLAG_PLAYING) == 0)
if ((mode == SongTimeline.MODE_PLAY || mode == SongTimeline.MODE_PLAY_JUMP_TO) && count != 0 && (mState & FLAG_PLAYING) == 0)
setFlag(FLAG_PLAYING);
Toast.makeText(this, getResources().getQuantityString(text, count, count), Toast.LENGTH_SHORT).show();
@ -1156,10 +1163,12 @@ public final class PlaybackService extends Service implements Handler.Callback,
* @param mode One of SongTimeline.MODE_*. Tells whether to play the songs
* immediately or enqueue them for later.
* @param query The query.
* @param jumpTo Passed to
* {@link SongTimeline#addSongs(int, android.database.Cursor, int)}
*/
public void addSongs(int mode, QueryTask query)
public void addSongs(int mode, QueryTask query, int jumpTo)
{
mHandler.sendMessage(mHandler.obtainMessage(QUERY, mode, 0, query));
mHandler.sendMessage(mHandler.obtainMessage(QUERY, mode, jumpTo, query));
}
/**
@ -1194,7 +1203,7 @@ public final class PlaybackService extends Service implements Handler.Callback,
}
String selection = "_id!=" + current.id;
addSongs(SongTimeline.MODE_PLAY_NEXT, MediaUtils.buildQuery(type, id, Song.FILLED_PROJECTION, selection));
addSongs(SongTimeline.MODE_PLAY_NEXT, MediaUtils.buildQuery(type, id, Song.FILLED_PROJECTION, selection), 0);
}
/**

View File

@ -133,9 +133,10 @@ public class Playlist {
ContentValues[] values = new ContentValues[count];
for (int i = 0; i != count; ++i) {
from.moveToPosition(i);
values[i] = new ContentValues(1);
values[i].put(MediaStore.Audio.Playlists.Members.PLAY_ORDER, Integer.valueOf(base + i));
values[i].put(MediaStore.Audio.Playlists.Members.AUDIO_ID, from.getLong(0));
ContentValues value = new ContentValues(2);
value.put(MediaStore.Audio.Playlists.Members.PLAY_ORDER, Integer.valueOf(base + i));
value.put(MediaStore.Audio.Playlists.Members.AUDIO_ID, from.getLong(0));
values[i] = value;
}
resolver.bulkInsert(uri, values);
}

View File

@ -0,0 +1,146 @@
/*
* 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.app.Activity;
import android.content.Intent;
import android.graphics.Color;
import android.os.Bundle;
import android.os.HandlerThread;
import android.os.Looper;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.AbsListView;
import android.widget.AdapterView;
import android.widget.Button;
/**
* The playlist activity where playlist songs can be viewed and reordered.
*/
public class PlaylistActivity extends Activity
implements View.OnClickListener
, AbsListView.OnItemClickListener
{
private Looper mLooper;
private DragListView mListView;
private PlaylistAdapter mAdapter;
private long mPlaylistId;
private boolean mEditing;
private Button mEditButton;
private Button mDeleteButton;
@Override
public void onCreate(Bundle state)
{
super.onCreate(state);
HandlerThread thread = new HandlerThread(getClass().getName());
thread.start();
MediaView.init(this);
setContentView(R.layout.playlist_activity);
DragListView view = (DragListView)findViewById(R.id.playlist);
view.setCacheColorHint(Color.BLACK);
view.setDivider(null);
view.setFastScrollEnabled(true);
view.setOnItemClickListener(this);
mListView = view;
View header = LayoutInflater.from(this).inflate(R.layout.playlist_buttons, null);
mEditButton = (Button)header.findViewById(R.id.edit);
mEditButton.setOnClickListener(this);
mDeleteButton = (Button)header.findViewById(R.id.delete);
mDeleteButton.setOnClickListener(this);
view.addHeaderView(header);
mLooper = thread.getLooper();
mAdapter = new PlaylistAdapter(this, mLooper);
view.setAdapter(mAdapter);
onNewIntent(getIntent());
}
@Override
public void onDestroy()
{
mLooper.quit();
super.onDestroy();
}
@Override
public void onNewIntent(Intent intent)
{
long id = intent.getLongExtra("playlist", 0);
mAdapter.setPlaylistId(id);
setTitle(intent.getStringExtra("title"));
mPlaylistId = id;
}
/**
* Enable or disable edit mode, which allows songs to be reordered and
* removed.
*
* @param editing True to enable edit mode.
*/
public void setEditing(boolean editing)
{
mListView.setEditable(editing);
mAdapter.setEditable(editing);
int visible = editing ? View.GONE : View.VISIBLE;
mDeleteButton.setVisibility(visible);
mEditButton.setText(editing ? R.string.done : R.string.edit);
mEditing = editing;
}
@Override
public void onClick(View view)
{
switch (view.getId()) {
case R.id.edit:
setEditing(!mEditing);
break;
case R.id.delete:
Playlist.deletePlaylist(getContentResolver(), mPlaylistId);
finish();
break;
}
}
@Override
public void onItemClick(AdapterView<?> adapterView, View view, int position, long id)
{
if (view instanceof MediaView) {
MediaView mediaView = (MediaView)view;
if (mediaView.isRightBitmapPressed()) {
mAdapter.remove(id);
} else if (!mEditing) {
QueryTask query = MediaUtils.buildPlaylistQuery(mPlaylistId, Song.FILLED_PLAYLIST_PROJECTION, null);
PlaybackService.get(this).addSongs(SongTimeline.MODE_PLAY_JUMP_TO, query, position - mListView.getHeaderViewsCount());
}
}
}
}

View File

@ -0,0 +1,237 @@
/*
* 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.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.content.res.Resources;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.net.Uri;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.provider.MediaStore;
import android.view.View;
import android.view.ViewGroup;
import android.widget.CursorAdapter;
/**
* CursorAdapter backed by MediaStore playlists.
*/
public class PlaylistAdapter extends CursorAdapter implements Handler.Callback {
private static final String[] PROJECTION = new String[] {
MediaStore.Audio.Playlists.Members._ID,
MediaStore.Audio.Playlists.Members.TITLE,
MediaStore.Audio.Playlists.Members.ARTIST,
MediaStore.Audio.Playlists.Members.AUDIO_ID,
MediaStore.Audio.Playlists.Members.PLAY_ORDER,
};
private final Context mContext;
private final Handler mWorkerHandler;
private final Handler mUiHandler;
private long mPlaylistId;
private boolean mEditable;
private final Bitmap mDragBitmap;
private final Bitmap mDeleteBitmap;
/**
* Create a playlist adapter.
*
* @param context A context to use.
* @param worker A looper running a worker thread (to run queries on).
*/
public PlaylistAdapter(Context context, Looper worker)
{
super(context, null, false);
mContext = context;
mUiHandler = new Handler(this);
mWorkerHandler = new Handler(worker, this);
Resources res = context.getResources();
mDragBitmap = BitmapFactory.decodeResource(res, R.drawable.grabber);
mDeleteBitmap = BitmapFactory.decodeResource(res, R.drawable.close_normal);
}
/**
* Set the id of the backing playlist.
*
* @param id The MediaStore id of a playlist.
*/
public void setPlaylistId(long id)
{
mPlaylistId = id;
mWorkerHandler.sendEmptyMessage(MSG_RUN_QUERY);
}
/**
* Enabled or disable edit mode. Edit mode adds a drag grabber to the left
* side a views and a delete button to the right side of views.
*
* @param editable True to enable edit mode.
*/
public void setEditable(boolean editable)
{
mEditable = editable;
notifyDataSetInvalidated();
}
/**
* Update the values in the given view.
*/
@Override
public void bindView(View view, Context context, Cursor cursor)
{
MediaView mediaView = (MediaView)view;
mediaView.updateMedia(cursor, true);
mediaView.setShowBitmaps(mEditable);
}
/**
* Generate a new view.
*/
@Override
public View newView(Context context, Cursor cursor, ViewGroup parent)
{
return new MediaView(context, mDragBitmap, mDeleteBitmap);
}
/**
* Re-run the query. Should be run on worker thread.
*/
public static final int MSG_RUN_QUERY = 1;
/**
* Update the cursor. Must be run on UI thread.
*/
public static final int MSG_UPDATE_CURSOR = 2;
@Override
public boolean handleMessage(Message message)
{
switch (message.what) {
case MSG_RUN_QUERY: {
Cursor cursor = runQuery(mContext.getContentResolver());
mUiHandler.sendMessage(mUiHandler.obtainMessage(MSG_UPDATE_CURSOR, cursor));
break;
}
case MSG_UPDATE_CURSOR:
changeCursor((Cursor)message.obj);
break;
default:
return false;
}
return true;
}
/**
* Query the playlist songs.
*
* @param resolver A ContentResolver to query with.
* @return The resulting cursor.
*/
private Cursor runQuery(ContentResolver resolver)
{
QueryTask query = MediaUtils.buildPlaylistQuery(mPlaylistId, PROJECTION, null);
return query.runQuery(resolver);
}
/**
* Move a song to a new position.
*
* @param from The old position.
* @param to The new position.
*/
public void move(int from, int to)
{
if (from == to)
// easy mode
return;
int count = getCount();
if (to >= count || from >= count)
// this can happen when the adapter changes during the drag
return;
// The Android API contains a method to move a playlist item, however,
// it has only been available since Froyo and doesn't seem to work
// after a song has been removed from the playlist (I think?).
ContentResolver resolver = mContext.getContentResolver();
Uri uri = MediaStore.Audio.Playlists.Members.getContentUri("external", mPlaylistId);
Cursor cursor = getCursor();
int start = Math.min(from, to);
int end = Math.max(from, to);
long order;
if (start == 0) {
order = 0;
} else {
cursor.moveToPosition(start - 1);
order = cursor.getLong(4) + 1;
}
cursor.moveToPosition(end);
long endOrder = cursor.getLong(4);
// clear the rows we are replacing
String[] args = new String[] { Long.toString(order), Long.toString(endOrder) };
resolver.delete(uri, "play_order >= ? AND play_order <= ?", args);
// create the new rows
ContentValues[] values = new ContentValues[end - start + 1];
for (int i = start, j = 0; i <= end; ++i, ++j, ++order) {
cursor.moveToPosition(i == to ? from : i > to ? i - 1 : i + 1);
ContentValues value = new ContentValues(2);
value.put(MediaStore.Audio.Playlists.Members.PLAY_ORDER, Long.valueOf(order));
value.put(MediaStore.Audio.Playlists.Members.AUDIO_ID, cursor.getLong(3));
values[j] = value;
}
// insert the new rows
resolver.bulkInsert(uri, values);
changeCursor(runQuery(resolver));
}
/**
* Remove the song with the given id.
*
* @param id The MediaStore id of the row to remove.
*/
public void remove(long id)
{
ContentResolver resolver = mContext.getContentResolver();
Uri uri = MediaStore.Audio.Playlists.Members.getContentUri("external", mPlaylistId);
resolver.delete(ContentUris.withAppendedId(uri, id), null, null);
changeCursor(runQuery(resolver));
}
}

View File

@ -77,7 +77,7 @@ public class Song implements Comparable<Song> {
MediaStore.Audio.Playlists.Members.ALBUM_ID,
MediaStore.Audio.Playlists.Members.ARTIST_ID,
MediaStore.Audio.Playlists.Members.DURATION,
MediaStore.Audio.Media.TRACK,
MediaStore.Audio.Playlists.Members.TRACK,
};
/**

View File

@ -74,21 +74,28 @@ public final class SongTimeline {
/**
* Clear the timeline and use only the provided songs.
*
* @see SongTimeline#addSongs(int,Cursor)
* @see SongTimeline#addSongs(int,Cursor,int)
*/
public static final int MODE_PLAY = 0;
/**
* Clear the queue and add the songs after the current song.
*
* @see SongTimeline#addSongs(int,Cursor)
* @see SongTimeline#addSongs(int,Cursor,int)
*/
public static final int MODE_PLAY_NEXT = 1;
/**
* Add the songs at the end of the timeline, clearing random songs.
*
* @see SongTimeline#addSongs(int,Cursor)
* @see SongTimeline#addSongs(int,Cursor,int)
*/
public static final int MODE_ENQUEUE = 2;
/**
* Like play mode, but make the current position point to the song at
* the given position.
*
* @see SongTimeline#addSongs(int,Cursor,int)
*/
public static final int MODE_PLAY_JUMP_TO = 3;
/**
* Disable shuffle.
@ -479,9 +486,10 @@ public final class SongTimeline {
*
* @param mode How to add the songs. One of SongTimeline.MODE_*.
* @param cursor The cursor to fill from.
* @param jumpTo The position to jump to for MODE_PLAY_JUMP_TO.
* @return The number of songs that were added.
*/
public int addSongs(int mode, Cursor cursor)
public int addSongs(int mode, Cursor cursor, int jumpTo)
{
if (cursor == null)
return 0;
@ -506,6 +514,7 @@ public final class SongTimeline {
timeline.subList(mCurrentPos + 1, timeline.size()).clear();
break;
case MODE_PLAY:
case MODE_PLAY_JUMP_TO:
timeline.clear();
mCurrentPos = 0;
break;
@ -515,16 +524,22 @@ public final class SongTimeline {
int start = timeline.size();
Song jumpSong = null;
for (int j = 0; j != count; ++j) {
cursor.moveToPosition(j);
Song song = new Song(-1);
song.populate(cursor);
timeline.add(song);
if (j == jumpTo)
jumpSong = song;
}
if (mShuffleMode != SHUFFLE_NONE)
MediaUtils.shuffle(timeline.subList(start, timeline.size()), mShuffleMode == SHUFFLE_ALBUMS);
if (mode == MODE_PLAY_JUMP_TO && jumpSong != null)
mCurrentPos = timeline.indexOf(jumpSong);
broadcastChangedSongs();
}