Merge pull request #2758 from sigmabeta/android-tv-game-picker

Android TV: Implement new UI for Main Activity.
This commit is contained in:
Ryan Houdek 2015-07-26 16:54:27 -05:00
commit 0ba970008f
18 changed files with 608 additions and 33 deletions

View File

@ -1,7 +1,8 @@
apply plugin: 'com.android.application'
android {
compileSdkVersion 21
// Leanback support requires >22
compileSdkVersion 22
buildToolsVersion "22.0.1"
lintOptions {
@ -80,6 +81,9 @@ dependencies {
compile 'com.android.support:recyclerview-v7:22.2.0'
compile 'com.android.support:design:22.2.0'
// Android TV UI libraries.
compile 'com.android.support:leanback-v17:22.2.0'
// For showing the banner as a circle a-la Material Design Guidelines
compile 'de.hdodenhof:circleimageview:1.2.2'

View File

@ -33,11 +33,22 @@
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LEANBACK_LAUNCHER"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<activity
android:name=".activities.TvMainActivity"
android:theme="@style/DolphinTvGamecube">
<!-- This intentfilter marks this Activity as the one that gets launched from Home screen. -->
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LEANBACK_LAUNCHER"/>
</intent-filter>
</activity>
<activity
android:name=".activities.AddDirectoryActivity"
android:theme="@style/DolphinGamecube"

View File

@ -50,8 +50,8 @@ public final class EmulationActivity extends AppCompatActivity
private boolean mSystemUiVisible;
private boolean mMenuVisible;
private static Interpolator sDecelerator = new DecelerateInterpolator();
private static Interpolator sAccelerator = new AccelerateInterpolator();
private static final Interpolator sDecelerator = new DecelerateInterpolator();
private static final Interpolator sAccelerator = new AccelerateInterpolator();
/**
* Handlers are a way to pass a message to an Activity telling it to do something

View File

@ -35,7 +35,7 @@ import org.dolphinemu.dolphinemu.services.AssetCopyService;
*/
public final class MainActivity extends AppCompatActivity implements LoaderManager.LoaderCallbacks<Cursor>
{
private static final int REQUEST_ADD_DIRECTORY = 1;
public static final int REQUEST_ADD_DIRECTORY = 1;
public static final int REQUEST_EMULATE_GAME = 2;
/**
@ -139,6 +139,7 @@ public final class MainActivity extends AppCompatActivity implements LoaderManag
{
fragment.refreshScreenshotAtPosition(resultCode);
}
break;
}
}
@ -226,7 +227,7 @@ public final class MainActivity extends AppCompatActivity implements LoaderManag
GameProvider.URI_GAME, // URI of table to query
null, // Return all columns
GameDatabase.KEY_GAME_PLATFORM + " = ?", // Select by platform
new String[]{Integer.toString(id)}, // Platform id is Loader id minus 1
new String[]{Integer.toString(id)}, // Platform id is Loader id
GameDatabase.KEY_GAME_TITLE + " asc" // Sort by game name, ascending order
);

View File

@ -0,0 +1,290 @@
package org.dolphinemu.dolphinemu.activities;
import android.app.Activity;
import android.app.ActivityOptions;
import android.app.FragmentManager;
import android.content.Intent;
import android.content.SharedPreferences;
import android.database.Cursor;
import android.os.Bundle;
import android.preference.PreferenceManager;
import android.support.v17.leanback.app.BrowseFragment;
import android.support.v17.leanback.database.CursorMapper;
import android.support.v17.leanback.widget.ArrayObjectAdapter;
import android.support.v17.leanback.widget.CursorObjectAdapter;
import android.support.v17.leanback.widget.HeaderItem;
import android.support.v17.leanback.widget.ListRow;
import android.support.v17.leanback.widget.ListRowPresenter;
import android.support.v17.leanback.widget.OnItemViewClickedListener;
import android.support.v17.leanback.widget.Presenter;
import android.support.v17.leanback.widget.Row;
import android.support.v17.leanback.widget.RowPresenter;
import android.widget.Toast;
import org.dolphinemu.dolphinemu.NativeLibrary;
import org.dolphinemu.dolphinemu.R;
import org.dolphinemu.dolphinemu.adapters.GameRowPresenter;
import org.dolphinemu.dolphinemu.adapters.SettingsRowPresenter;
import org.dolphinemu.dolphinemu.model.Game;
import org.dolphinemu.dolphinemu.model.GameDatabase;
import org.dolphinemu.dolphinemu.model.GameProvider;
import org.dolphinemu.dolphinemu.model.TvSettingsItem;
import org.dolphinemu.dolphinemu.services.AssetCopyService;
import org.dolphinemu.dolphinemu.viewholders.TvGameViewHolder;
public final class TvMainActivity extends Activity
{
protected BrowseFragment mBrowseFragment;
private ArrayObjectAdapter mRowsAdapter;
@Override
protected void onCreate(Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_tv_main);
final FragmentManager fragmentManager = getFragmentManager();
mBrowseFragment = (BrowseFragment) fragmentManager.findFragmentById(
R.id.fragment_game_list);
// Set display parameters for the BrowseFragment
mBrowseFragment.setHeadersState(BrowseFragment.HEADERS_ENABLED);
mBrowseFragment.setTitle(getString(R.string.app_name));
mBrowseFragment.setBadgeDrawable(getResources().getDrawable(
R.drawable.ic_launcher, null));
mBrowseFragment.setBrandColor(getResources().getColor(R.color.dolphin_blue_dark));
buildRowsAdapter();
mBrowseFragment.setOnItemViewClickedListener(
new OnItemViewClickedListener()
{
@Override
public void onItemClicked(Presenter.ViewHolder itemViewHolder, Object item, RowPresenter.ViewHolder rowViewHolder, Row row)
{
// Special case: user clicked on a settings row item.
if (item instanceof TvSettingsItem)
{
TvSettingsItem settingsItem = (TvSettingsItem) item;
switch (settingsItem.getItemId())
{
case R.id.menu_refresh:
getContentResolver().insert(GameProvider.URI_REFRESH, null);
// TODO Let the Activity know the data is refreshed in some other, better way.
recreate();
break;
case R.id.menu_settings:
// Launch the Settings Actvity.
Intent settings = new Intent(TvMainActivity.this, SettingsActivity.class);
startActivity(settings);
break;
case R.id.button_add_directory:
Intent fileChooser = new Intent(TvMainActivity.this, AddDirectoryActivity.class);
// The second argument to this method is read below in onActivityResult().
startActivityForResult(fileChooser, MainActivity.REQUEST_ADD_DIRECTORY);
break;
default:
Toast.makeText(TvMainActivity.this, "Unimplemented menu option.", Toast.LENGTH_SHORT).show();
break;
}
}
else
{
TvGameViewHolder holder = (TvGameViewHolder) itemViewHolder;
// Start the emulation activity and send the path of the clicked ISO to it.
Intent intent = new Intent(TvMainActivity.this, EmulationActivity.class);
intent.putExtra("SelectedGame", holder.path);
intent.putExtra("SelectedTitle", holder.title);
intent.putExtra("ScreenPath", holder.screenshotPath);
ActivityOptions options = ActivityOptions.makeSceneTransitionAnimation(
TvMainActivity.this,
holder.imageScreenshot,
"image_game_screenshot");
startActivity(intent, options.toBundle());
}
}
});
// Stuff in this block only happens when this activity is newly created (i.e. not a rotation)
if (savedInstanceState == null)
{
NativeLibrary.SetUserDirectory(""); // Auto-Detect
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this);
boolean assetsCopied = preferences.getBoolean("assetsCopied", false);
// Only perform these extensive copy operations once.
if (!assetsCopied)
{
// Copy assets into appropriate locations.
Intent copyAssets = new Intent(this, AssetCopyService.class);
startService(copyAssets);
}
}
}
/**
* Callback from AddDirectoryActivity. Applies any changes necessary to the GameGridActivity.
*
* @param requestCode An int describing whether the Activity that is returning did so successfully.
* @param resultCode An int describing what Activity is giving us this callback.
* @param result The information the returning Activity is providing us.
*/
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent result)
{
switch (requestCode)
{
case MainActivity.REQUEST_ADD_DIRECTORY:
// If the user picked a file, as opposed to just backing out.
if (resultCode == RESULT_OK)
{
// Sanity check to make sure the Activity that just returned was the AddDirectoryActivity;
// other activities might use this callback in the future (don't forget to change Javadoc!)
if (requestCode == MainActivity.REQUEST_ADD_DIRECTORY)
{
// TODO Let the Activity know the data is refreshed in some other, better way.
recreate();
}
}
break;
}
}
private void buildRowsAdapter()
{
mRowsAdapter = new ArrayObjectAdapter(new ListRowPresenter());
// For each platform
for (int platformIndex = 0; platformIndex <= Game.PLATFORM_ALL; ++platformIndex)
{
ListRow row = buildGamesRow(platformIndex);
// Add row to the adapter only if it is not empty.
if (row != null)
{
mRowsAdapter.add(row);
}
}
ListRow settingsRow = buildSettingsRow();
mRowsAdapter.add(settingsRow);
mBrowseFragment.setAdapter(mRowsAdapter);
}
private ListRow buildGamesRow(int platform)
{
// Create an adapter for this row.
CursorObjectAdapter row = new CursorObjectAdapter(new GameRowPresenter());
Cursor games;
if (platform == Game.PLATFORM_ALL)
{
// Get all games.
games = getContentResolver().query(
GameProvider.URI_GAME, // URI of table to query
null, // Return all columns
null, // Return all games
null, // Return all games
GameDatabase.KEY_GAME_TITLE + " asc" // Sort by game name, ascending order
);
}
else
{
// Get games for this particular platform.
games = getContentResolver().query(
GameProvider.URI_GAME, // URI of table to query
null, // Return all columns
GameDatabase.KEY_GAME_PLATFORM + " = ?", // Select by platform
new String[]{Integer.toString(platform)}, // Platform id
GameDatabase.KEY_GAME_TITLE + " asc" // Sort by game name, ascending order
);
}
// If cursor is empty, don't return a Row.
if (!games.moveToFirst())
{
return null;
}
row.changeCursor(games);
row.setMapper(new CursorMapper()
{
@Override
protected void bindColumns(Cursor cursor)
{
// No-op? Not sure what this does.
}
@Override
protected Object bind(Cursor cursor)
{
return Game.fromCursor(cursor);
}
});
String headerName;
switch (platform)
{
case Game.PLATFORM_GC:
headerName = "GameCube Games";
break;
case Game.PLATFORM_WII:
headerName = "Wii Games";
break;
case Game.PLATFORM_WII_WARE:
headerName = "WiiWare";
break;
case Game.PLATFORM_ALL:
headerName = "All Games";
break;
default:
headerName = "Error";
break;
}
// Create a header for this row.
HeaderItem header = new HeaderItem(platform, headerName);
// Create the row, passing it the filled adapter and the header, and give it to the master adapter.
return new ListRow(header, row);
}
private ListRow buildSettingsRow()
{
ArrayObjectAdapter rowItems = new ArrayObjectAdapter(new SettingsRowPresenter());
rowItems.add(new TvSettingsItem(R.id.menu_refresh,
R.drawable.ic_refresh_tv,
R.string.grid_menu_refresh));
rowItems.add(new TvSettingsItem(R.id.menu_settings,
R.drawable.ic_settings_tv,
R.string.grid_menu_settings));
rowItems.add(new TvSettingsItem(R.id.button_add_directory,
R.drawable.ic_add_tv,
R.string.add_directory_title));
// Create a header for this row.
HeaderItem header = new HeaderItem(R.string.settings, getString(R.string.settings));
return new ListRow(header, rowItems);
}
}

View File

@ -0,0 +1,118 @@
package org.dolphinemu.dolphinemu.adapters;
import android.graphics.Bitmap;
import android.support.v17.leanback.widget.ImageCardView;
import android.support.v17.leanback.widget.Presenter;
import android.view.ViewGroup;
import android.widget.ImageView;
import com.squareup.picasso.Picasso;
import org.dolphinemu.dolphinemu.R;
import org.dolphinemu.dolphinemu.model.Game;
import org.dolphinemu.dolphinemu.viewholders.TvGameViewHolder;
/**
* The Leanback library / docs call this a Presenter, but it works very
* similarly to a RecyclerView.ViewHolder.
*/
public final class GameRowPresenter extends Presenter
{
public ViewHolder onCreateViewHolder(ViewGroup parent)
{
// Create a new view.
ImageCardView gameCard = new ImageCardView(parent.getContext())
{
@Override
public void setSelected(boolean selected)
{
setCardBackground(this, selected);
super.setSelected(selected);
}
};
gameCard.setMainImageAdjustViewBounds(true);
gameCard.setMainImageDimensions(480, 320);
gameCard.setMainImageScaleType(ImageView.ScaleType.CENTER_CROP);
gameCard.setFocusable(true);
gameCard.setFocusableInTouchMode(true);
setCardBackground(gameCard, false);
// Use that view to create a ViewHolder.
return new TvGameViewHolder(gameCard);
}
public void onBindViewHolder(ViewHolder viewHolder, Object item)
{
TvGameViewHolder holder = (TvGameViewHolder) viewHolder;
Game game = (Game) item;
String screenPath = game.getScreenshotPath();
// Fill in the view contents.
Picasso.with(holder.imageScreenshot.getContext())
.load(screenPath)
.fit()
.centerCrop()
.noFade()
.noPlaceholder()
.config(Bitmap.Config.RGB_565)
.error(R.drawable.no_banner)
.into(holder.imageScreenshot);
holder.cardParent.setTitleText(game.getTitle());
holder.cardParent.setContentText(game.getCompany());
// TODO These shouldn't be necessary once the move to a DB-based model is complete.
holder.gameId = game.getGameId();
holder.path = game.getPath();
holder.title = game.getTitle();
holder.description = game.getDescription();
holder.country = game.getCountry();
holder.company = game.getCompany();
holder.screenshotPath = game.getScreenshotPath();
switch (game.getPlatform())
{
case Game.PLATFORM_GC:
holder.cardParent.setTag(R.color.dolphin_accent_gamecube);
break;
case Game.PLATFORM_WII:
holder.cardParent.setTag(R.color.dolphin_accent_wii);
break;
case Game.PLATFORM_WII_WARE:
holder.cardParent.setTag(R.color.dolphin_accent_wiiware);
break;
default:
holder.cardParent.setTag(android.R.color.holo_red_dark);
break;
}
}
public void onUnbindViewHolder(ViewHolder viewHolder)
{
// no op
}
public void setCardBackground(ImageCardView view, boolean selected)
{
int backgroundColor;
if (selected)
{
// TODO: 7/20/15 Try using view tag to set color
backgroundColor = (int) view.getTag();
}
else
{
backgroundColor = R.color.tv_card_unselected;
}
view.setInfoAreaBackgroundColor(view.getResources().getColor(backgroundColor));
}
}

View File

@ -0,0 +1,47 @@
package org.dolphinemu.dolphinemu.adapters;
import android.content.res.Resources;
import android.support.v17.leanback.widget.ImageCardView;
import android.support.v17.leanback.widget.Presenter;
import android.view.ViewGroup;
import org.dolphinemu.dolphinemu.model.TvSettingsItem;
import org.dolphinemu.dolphinemu.viewholders.TvSettingsViewHolder;
public final class SettingsRowPresenter extends Presenter
{
public Presenter.ViewHolder onCreateViewHolder(ViewGroup parent)
{
// Create a new view.
ImageCardView settingsCard = new ImageCardView(parent.getContext());
settingsCard.setMainImageAdjustViewBounds(true);
settingsCard.setMainImageDimensions(192, 160);
settingsCard.setFocusable(true);
settingsCard.setFocusableInTouchMode(true);
// Use that view to create a ViewHolder.
return new TvSettingsViewHolder(settingsCard);
}
public void onBindViewHolder(Presenter.ViewHolder viewHolder, Object item)
{
TvSettingsViewHolder holder = (TvSettingsViewHolder) viewHolder;
TvSettingsItem settingsItem = (TvSettingsItem) item;
Resources resources = holder.cardParent.getResources();
holder.itemId = settingsItem.getItemId();
holder.cardParent.setTitleText(resources.getString(settingsItem.getLabelId()));
holder.cardParent.setMainImage(resources.getDrawable(settingsItem.getIconId(), null));
}
public void onUnbindViewHolder(Presenter.ViewHolder viewHolder)
{
// no op
}
}

View File

@ -3,13 +3,12 @@ package org.dolphinemu.dolphinemu.model;
import android.content.ContentValues;
import android.database.Cursor;
import java.io.File;
public final class Game
{
public static final int PLATFORM_GC = 0;
public static final int PLATFORM_WII = 1;
public static final int PLATFORM_WII_WARE = 2;
public static final int PLATFORM_ALL = 3;
// Copied from IVolume::ECountry. Update these if that is ever modified.
public static final int COUNTRY_EUROPE = 0;
@ -33,13 +32,13 @@ public final class Game
private String mDescription;
private String mPath;
private String mGameId;
private String mScreenshotFolderPath;
private String mScreenshotPath;
private String mCompany;
private int mPlatform;
private int mCountry;
public Game(int platform, String title, String description, int country, String path, String gameId, String company)
public Game(int platform, String title, String description, int country, String path, String gameId, String company, String screenshotPath)
{
mPlatform = platform;
mTitle = title;
@ -48,7 +47,7 @@ public final class Game
mPath = path;
mGameId = gameId;
mCompany = company;
mScreenshotFolderPath = PATH_SCREENSHOT_FOLDER + getGameId() + "/";
mScreenshotPath = screenshotPath;
}
public int getPlatform()
@ -86,27 +85,9 @@ public final class Game
return mGameId;
}
public String getScreenshotFolderPath()
public String getScreenshotPath()
{
return mScreenshotFolderPath;
}
public String getScreenPath()
{
// Count how many screenshots are available, so we can use the most recent one.
File screenshotFolder = new File(mScreenshotFolderPath.substring(mScreenshotFolderPath.indexOf('s') - 1));
int screenCount = 0;
if (screenshotFolder.isDirectory())
{
screenCount = screenshotFolder.list().length;
}
String screenPath = mScreenshotFolderPath
+ getGameId() + "-"
+ screenCount + ".png";
return screenPath;
return mScreenshotPath;
}
public static ContentValues asContentValues(int platform, String title, String description, int country, String path, String gameId, String company)
@ -135,6 +116,7 @@ public final class Game
cursor.getInt(GameDatabase.GAME_COLUMN_COUNTRY),
cursor.getString(GameDatabase.GAME_COLUMN_PATH),
cursor.getString(GameDatabase.GAME_COLUMN_GAME_ID),
cursor.getString(GameDatabase.GAME_COLUMN_COMPANY));
cursor.getString(GameDatabase.GAME_COLUMN_COMPANY),
cursor.getString(GameDatabase.GAME_COLUMN_SCREENSHOT_PATH));
}
}

View File

@ -0,0 +1,31 @@
package org.dolphinemu.dolphinemu.model;
public final class TvSettingsItem
{
private final int mItemId;
private final int mIconId;
private final int mLabelId;
public TvSettingsItem(int itemId, int iconId, int labelId)
{
mItemId = itemId;
mIconId = iconId;
mLabelId = labelId;
}
public int getItemId()
{
return mItemId;
}
public int getIconId()
{
return mIconId;
}
public int getLabelId()
{
return mLabelId;
}
}

View File

@ -0,0 +1,39 @@
package org.dolphinemu.dolphinemu.viewholders;
import android.support.v17.leanback.widget.ImageCardView;
import android.support.v17.leanback.widget.Presenter;
import android.view.View;
import android.widget.ImageView;
/**
* A simple class that stores references to views so that the GameAdapter doesn't need to
* keep calling findViewById(), which is expensive.
*/
public final class TvGameViewHolder extends Presenter.ViewHolder
{
public ImageCardView cardParent;
public ImageView imageScreenshot;
public String gameId;
// TODO Not need any of this stuff. Currently only the properties dialog needs it.
public String path;
public String title;
public String description;
public int country;
public String company;
public String screenshotPath;
public int backgroundColor;
public TvGameViewHolder(View itemView)
{
super(itemView);
itemView.setTag(this);
cardParent = (ImageCardView) itemView;
imageScreenshot = cardParent.getMainImageView();
}
}

View File

@ -0,0 +1,23 @@
package org.dolphinemu.dolphinemu.viewholders;
import android.support.v17.leanback.widget.ImageCardView;
import android.support.v17.leanback.widget.Presenter;
import android.view.View;
public final class TvSettingsViewHolder extends Presenter.ViewHolder
{
public ImageCardView cardParent;
// Determines what action to take when this item is clicked.
public int itemId;
public TvSettingsViewHolder(View itemView)
{
super(itemView);
itemView.setTag(this);
cardParent = (ImageCardView) itemView;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<fragment
android:name="android.support.v17.leanback.app.BrowseFragment"
android:id="@+id/fragment_game_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
/>
</FrameLayout>

View File

@ -33,7 +33,6 @@
android:layout_marginLeft="16dp"
android:layout_marginRight="16dp"
android:layout_marginTop="16dp"
android:layout_toStartOf="@+id/button_details"
android:ellipsize="end"
android:lines="1"
android:maxLines="1"

View File

@ -8,4 +8,6 @@
<color name="dolphin_accent_gamecube">#651fff</color>
<color name="circle_grey">#bdbdbd</color>
<color name="tv_card_unselected">#444444</color>
</resources>

View File

@ -126,6 +126,21 @@
<item name="textAllCaps">false</item>
</style>
<!-- Android TV Themes -->
<style name="DolphinTvBase" parent="Theme.Leanback">
<item name="colorPrimary">@color/dolphin_blue</item>
<item name="colorPrimaryDark">@color/dolphin_blue_dark</item>
<!--enable window content transitions-->
<item name="android:windowContentTransitions">true</item>
<item name="android:windowAllowEnterTransitionOverlap">true</item>
<item name="android:windowAllowReturnTransitionOverlap">true</item>
</style>
<style name="DolphinTvGamecube" parent="DolphinTvBase">
<item name="colorAccent">@color/dolphin_accent_gamecube</item>
</style>
<style name="InGameMenuOption" parent="Widget.AppCompat.Button.Borderless">
<item name="android:textSize">16sp</item>
<item name="android:fontFamily">sans-serif-condensed</item>