Added Coverflow for game covers

This commit is contained in:
izzy2lost
2025-08-30 15:11:45 -04:00
parent a27b5b143b
commit 8f2606956e
8 changed files with 519 additions and 38 deletions
+2
View File
@@ -22,3 +22,5 @@ app/.cxx/
/PCSX2_ARM64
build/reports/problems/problems-report.html
crash.txt
/AetherSX2-main
crash-NEW.txt
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

@@ -29,31 +29,57 @@ public class CoversAdapter extends RecyclerView.Adapter<CoversAdapter.VH> {
private final String[] localPaths; // absolute file paths for cached covers (may be null)
private final OnItemClick onItemClick;
private final OnItemLongClick onItemLongClick;
private final int itemLayoutResId;
private int overrideItemWidthPx = 0;
public CoversAdapter(Context context, String[] titles, String[] coverUrls, String[] localPaths, OnItemClick click) {
this(context, titles, coverUrls, localPaths, click, null);
this(context, titles, coverUrls, localPaths, R.layout.item_cover, click, null);
}
public CoversAdapter(Context context, String[] titles, String[] coverUrls, String[] localPaths, OnItemClick click, OnItemLongClick longClick) {
public CoversAdapter(Context context, String[] titles, String[] coverUrls, String[] localPaths, int itemLayoutResId, OnItemClick click, OnItemLongClick longClick) {
this.context = context;
this.titles = titles;
this.coverUrls = coverUrls;
this.localPaths = localPaths;
this.itemLayoutResId = itemLayoutResId;
this.onItemClick = click;
this.onItemLongClick = longClick;
setHasStableIds(true);
}
public void setItemWidthPx(int widthPx) {
if (widthPx != overrideItemWidthPx) {
overrideItemWidthPx = widthPx;
notifyDataSetChanged();
}
}
@NonNull
@Override
public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_cover, parent, false);
View v = LayoutInflater.from(parent.getContext()).inflate(itemLayoutResId, parent, false);
if (overrideItemWidthPx > 0) {
RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) v.getLayoutParams();
lp.width = overrideItemWidthPx;
v.setLayoutParams(lp);
}
return new VH(v);
}
@Override
public void onBindViewHolder(@NonNull VH holder, int position) {
holder.title.setText(titles[position]);
String local = (localPaths != null && position < localPaths.length) ? localPaths[position] : null;
int count = titles.length;
int real = (count == 0) ? 0 : (position % count);
// Ensure dynamic width is applied
if (overrideItemWidthPx > 0) {
RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) holder.itemView.getLayoutParams();
if (lp.width != overrideItemWidthPx) {
lp.width = overrideItemWidthPx;
holder.itemView.setLayoutParams(lp);
}
}
holder.title.setText(titles[real]);
String local = (localPaths != null && real < localPaths.length) ? localPaths[real] : null;
File localFile = null;
if (local != null) {
File f = new File(local);
@@ -69,15 +95,28 @@ public class CoversAdapter extends RecyclerView.Adapter<CoversAdapter.VH> {
.error(android.R.color.transparent)
.into(holder.cover);
} else {
// Do not load from network automatically; wait for explicit download
holder.cover.setImageDrawable(null);
// Show a default placeholder from resources/no-cover.png if present
File resDir = context.getExternalFilesDir("resources");
File placeholder = (resDir != null) ? new File(resDir, "no-cover.png") : null;
if (placeholder != null && placeholder.exists() && placeholder.length() > 0) {
Glide.with(context)
.load(placeholder)
.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
.fitCenter()
.placeholder(android.R.color.transparent)
.error(android.R.color.transparent)
.into(holder.cover);
} else {
// Fallback to logo if placeholder not found
holder.cover.setImageResource(R.drawable.psx2_logo2_fixed);
}
}
holder.itemView.setOnClickListener(v -> {
if (onItemClick != null) onItemClick.onClick(position);
if (onItemClick != null) onItemClick.onClick(real);
});
holder.itemView.setOnLongClickListener(v -> {
if (onItemLongClick != null) {
onItemLongClick.onLongClick(position);
onItemLongClick.onLongClick(real);
return true;
}
return false;
@@ -86,7 +125,15 @@ public class CoversAdapter extends RecyclerView.Adapter<CoversAdapter.VH> {
@Override
public int getItemCount() {
return titles.length;
return titles.length == 0 ? 0 : Integer.MAX_VALUE;
}
@Override
public long getItemId(int position) {
if (titles.length == 0) return RecyclerView.NO_ID;
int real = position % titles.length;
String t = titles[real];
return t != null ? t.hashCode() : real;
}
static class VH extends RecyclerView.ViewHolder {
@@ -20,10 +20,13 @@ import androidx.annotation.Nullable;
import android.app.Dialog;
import androidx.fragment.app.DialogFragment;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.PagerSnapHelper;
import androidx.recyclerview.widget.RecyclerView;
import java.util.Locale;
public class GamesCoverDialogFragment extends DialogFragment {
private boolean didInitialNudge = false;
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
@@ -35,7 +38,10 @@ public class GamesCoverDialogFragment extends DialogFragment {
private String[] coverUrls;
private String[] localPaths;
private RecyclerView rv;
private GridLayoutManager glm;
private LinearLayoutManager llm;
private PagerSnapHelper snapHelper;
private int lastRvW = -1, lastRvH = -1;
private boolean pendingResnap = false;
public interface OnGameSelectedListener {
void onGameSelected(String gameUri);
@@ -66,14 +72,7 @@ public class GamesCoverDialogFragment extends DialogFragment {
@Override
public void onResume() {
super.onResume();
// Re-assert fixed span (2/4) after resume to avoid any flips
if (rv != null && glm != null) {
int currentOrientation = getResources().getConfiguration().orientation;
int fixedSpan = (currentOrientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE) ? 4 : 2;
if (fixedSpan != glm.getSpanCount()) {
glm.setSpanCount(fixedSpan);
}
}
if (rv != null) applyCoverflowTransforms(rv);
}
@NonNull
@@ -90,25 +89,33 @@ public class GamesCoverDialogFragment extends DialogFragment {
rv = root.findViewById(R.id.recycler_covers);
rv.setHasFixedSize(true);
glm = new GridLayoutManager(requireContext(), 3);
rv.setLayoutManager(glm);
final int spacingPx = (int) (8 * getResources().getDisplayMetrics().density);
final int half = Math.max(1, spacingPx / 2);
rv.addItemDecoration(new RecyclerView.ItemDecoration() {
llm = new LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false);
rv.setLayoutManager(llm);
rv.setClipToPadding(false);
int sidePad = (int) (48 * getResources().getDisplayMetrics().density);
int vertPad = (int) (24 * getResources().getDisplayMetrics().density);
rv.setPadding(sidePad, vertPad, sidePad, vertPad);
// Snap to center item
snapHelper = new PagerSnapHelper();
snapHelper.attachToRecyclerView(rv);
// Scale/alpha transform based on distance from center
rv.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void getItemOffsets(android.graphics.Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
outRect.set(half, half, half, half);
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
super.onScrolled(recyclerView, dx, dy);
applyCoverflowTransforms(recyclerView);
}
@Override
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
super.onScrollStateChanged(recyclerView, newState);
if (newState == RecyclerView.SCROLL_STATE_IDLE) {
if (pendingResnap) {
resnapToCenter(recyclerView);
pendingResnap = false;
}
applyCoverflowTransforms(recyclerView);
}
}
});
rv.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
int currentOrientation = getResources().getConfiguration().orientation;
int fixedSpan = (currentOrientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE) ? 4 : 2;
if (fixedSpan != glm.getSpanCount()) { glm.setSpanCount(fixedSpan); }
});
root.post(() -> {
int currentOrientation = getResources().getConfiguration().orientation;
int fixedSpan = (currentOrientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE) ? 4 : 2;
if (fixedSpan != glm.getSpanCount()) { glm.setSpanCount(fixedSpan); }
});
titles = getArguments() != null ? getArguments().getStringArray(ARG_TITLES) : new String[0];
@@ -135,7 +142,7 @@ public class GamesCoverDialogFragment extends DialogFragment {
localPaths[i] = new java.io.File(getCoversDir(), serial + ".png").getAbsolutePath();
}
adapter = new CoversAdapter(requireContext(), titles, coverUrls, localPaths,
adapter = new CoversAdapter(requireContext(), titles, coverUrls, localPaths, R.layout.item_coverflow,
position -> {
if (listener != null && position >= 0 && position < uris.length) {
listener.onGameSelected(uris[position]);
@@ -148,6 +155,80 @@ public class GamesCoverDialogFragment extends DialogFragment {
}
});
rv.setAdapter(adapter);
// One-time tiny nudge to force snap/transform on some devices
rv.post(() -> {
if (!isAdded() || didInitialNudge) return;
// Anchor to a large middle position for "infinite" scroll
int n = titles != null ? titles.length : 0;
if (n > 0) {
int center = (1 << 29); // ~536 million
int startPos = center - (center % n);
llm.scrollToPosition(startPos);
}
rv.scrollBy(1, 0);
rv.scrollBy(-1, 0);
applyCoverflowTransforms(rv);
didInitialNudge = true;
});
// Reduce resize flicker and keep a few views ready
rv.setItemAnimator(null);
rv.setItemViewCacheSize(12);
// Ensure initial measurement + transforms run after first layout
rv.getViewTreeObserver().addOnGlobalLayoutListener(new android.view.ViewTreeObserver.OnGlobalLayoutListener() {
@Override public void onGlobalLayout() {
if (!isAdded()) return;
applyCoverflowTransforms(rv);
rv.getViewTreeObserver().removeOnGlobalLayoutListener(this);
}
});
// Resolve proper game titles using local YAML index if available (GameIndex/Redump).
// Falls back to native URI API, then filename if needed.
new Thread(() -> {
boolean changed = false;
for (int i = 0; i < uris.length; i++) {
try {
String t = TitleResolver.resolveTitleForUri(requireContext(), uris[i], titles[i]);
if (t != null && !t.isEmpty() && i < titles.length && !t.equals(titles[i])) {
titles[i] = t;
changed = true;
}
} catch (Throwable ignored) {}
}
if (changed && isAdded()) requireActivity().runOnUiThread(() -> adapter.notifyDataSetChanged());
}).start();
// Dynamically size items based on RecyclerView size and orientation
rv.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
final int rvW = right - left;
final int rvH = bottom - top;
if (rvW <= 0 || rvH <= 0) return;
if (rvW == lastRvW && rvH == lastRvH) return; // no real size change
lastRvW = rvW;
lastRvH = rvH;
rv.post(() -> {
if (!isAdded()) return;
boolean landscape = getResources().getConfiguration().orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE;
int titleDp = 36;
int extraDp = 16;
float density = getResources().getDisplayMetrics().density;
int reservedH = (int) ((titleDp + extraDp) * density) + (vertPad * 2);
int availableH = Math.max(0, rvH - reservedH);
float ratio = 567f / 878f;
int widthFromHeight = (int) (availableH * ratio);
int widthFromWidth = (int) (rvW * (landscape ? 0.35f : 0.55f));
int itemWidth = Math.max(160, Math.min(widthFromHeight, widthFromWidth));
adapter.setItemWidthPx(itemWidth);
// If user is scrolling, defer resnap until idle to avoid fighting gesture
if (rv.getScrollState() == RecyclerView.SCROLL_STATE_IDLE) {
resnapToCenter(rv);
applyCoverflowTransforms(rv);
} else {
pendingResnap = true;
}
});
});
View btnHome = root.findViewById(R.id.btn_home);
if (btnHome != null) btnHome.setOnClickListener(v -> dismissAllowingStateLoss());
@@ -157,6 +238,59 @@ public class GamesCoverDialogFragment extends DialogFragment {
return root;
}
private void applyCoverflowTransforms(@NonNull RecyclerView recyclerView) {
int rvCenterX = (recyclerView.getLeft() + recyclerView.getRight()) / 2;
boolean landscape = getResources().getConfiguration().orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE;
final float maxScale = 1.0f;
final float minScale = landscape ? 0.70f : 0.82f;
final float maxAlpha = 1.0f;
final float minAlpha = landscape ? 0.50f : 0.60f;
final float maxTiltDeg = landscape ? 22f : 16f;
final float density = getResources().getDisplayMetrics().density;
final float maxParallax = (landscape ? 14f : 18f) * density;
for (int i = 0; i < recyclerView.getChildCount(); i++) {
View child = recyclerView.getChildAt(i);
int childCenterX = (child.getLeft() + child.getRight()) / 2;
float dx = childCenterX - rvCenterX;
float dist = Math.abs(dx);
float norm = Math.min(1f, dist / (recyclerView.getWidth() * 0.5f));
float scale = maxScale - (maxScale - minScale) * norm;
float alpha = maxAlpha - (maxAlpha - minAlpha) * norm;
float tilt = Math.signum(dx) * maxTiltDeg * norm; // tilt away from center
child.setCameraDistance(8000f * density);
child.setScaleX(scale);
child.setScaleY(scale);
child.setAlpha(alpha);
child.setTranslationZ((1f - norm) * 10f);
child.setRotationY(tilt);
// Title parallax (gentle)
View title = child.findViewById(R.id.text_title);
if (title != null) {
float parallax = Math.max(-maxParallax, Math.min(maxParallax, -dx / (recyclerView.getWidth() * 0.5f) * maxParallax));
title.setTranslationX(parallax);
title.setAlpha(0.85f + 0.15f * (1f - norm));
}
// Shadow intensity scales with centeredness
View shadow = child.findViewById(R.id.view_shadow);
if (shadow != null) {
shadow.setAlpha((1f - norm) * 0.7f);
shadow.setScaleX(scale + 0.2f);
}
}
}
private void resnapToCenter(@NonNull RecyclerView rv) {
if (snapHelper == null || llm == null) return;
View snapView = snapHelper.findSnapView(llm);
if (snapView == null) return;
int[] dist = snapHelper.calculateDistanceToFinalSnap(llm, snapView);
if (dist != null && (dist[0] != 0 || dist[1] != 0)) {
rv.scrollBy(dist[0], dist[1]);
}
}
public void onStart() {
super.onStart();
Dialog d = getDialog();
@@ -36,7 +36,9 @@ public class NativeApp {
}
public static native void initialize(String path, int apiVer);
public static native String getGameTitle(String path);
public static native String getGameTitle(String path);
// New: Resolve game title from a URI (content:// or file://). Implement in native when available.
public static native String getGameTitleFromUri(String gameUri);
public static native String getGameSerial();
public static native float getFPS();
@@ -0,0 +1,235 @@
package com.izzy2lost.psx2;
import android.content.Context;
import android.net.Uri;
import android.content.SharedPreferences;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import org.json.JSONObject;
import org.json.JSONTokener;
/**
* Loads game title index from resources/GameIndex.yaml or resources/RedumpDatabase.yaml
* and resolves human titles from serials extracted via native APIs.
*/
public final class TitleResolver {
private static Map<String, String> sSerialToTitle; // UPPERCASE SERIAL -> Title
private static boolean sLoaded;
private TitleResolver() {}
public static synchronized void ensureLoaded(Context ctx) {
if (sLoaded && sSerialToTitle != null) return;
sSerialToTitle = new HashMap<>();
File base = ctx.getExternalFilesDir(null);
if (base == null) base = ctx.getFilesDir();
File resDir = new File(base, "resources");
// Prefer JSON cache if available
File jsonCache = new File(resDir, "gameindex.json");
if (loadJsonIfPresent(jsonCache, sSerialToTitle)) {
sLoaded = true;
return;
}
// Try both files if present
loadYamlSafe(new File(resDir, "GameIndex.yaml"), sSerialToTitle);
loadYamlSafe(new File(resDir, "RedumpDatabase.yaml"), sSerialToTitle);
// Write JSON cache for faster subsequent loads
writeJsonSafe(jsonCache, sSerialToTitle);
sLoaded = true;
}
public static String resolveTitleForUri(Context ctx, String uriString, String fallback) {
try {
// 1) Check per-URI cache
String cached = getCachedTitle(ctx, uriString);
if (cached != null && !cached.isEmpty()) return cached;
// 2) Ensure index loaded (JSON fast path or YAML)
ensureLoaded(ctx);
// 3) Resolve serial via native; if missing, try filename hint
String serial = null;
try { serial = NativeApp.getGameSerial(uriString); } catch (Throwable ignored) {}
if (serial == null || serial.isEmpty()) {
Uri u = Uri.parse(uriString);
String name = u.getLastPathSegment();
if (name != null) serial = normalizeCandidate(name);
}
// 4) Lookup in index
if (serial != null) {
serial = normalizeSerial(serial);
String title = sSerialToTitle.get(serial);
if (title != null && !title.isEmpty()) {
putCachedTitle(ctx, uriString, title);
return title;
}
}
// 5) Fallback to native URI title if available
String nativeTitle = null;
try { nativeTitle = NativeApp.getGameTitleFromUri(uriString); } catch (Throwable ignored) {}
if (nativeTitle != null && !nativeTitle.isEmpty()) {
putCachedTitle(ctx, uriString, nativeTitle);
return nativeTitle;
}
} catch (Throwable ignored) {}
return fallback;
}
private static void loadYamlSafe(File file, Map<String, String> out) {
if (file == null || !file.exists()) return;
try (BufferedReader br = new BufferedReader(new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8))) {
String line;
String pendingSerial = null;
int currentIndent = 0;
while ((line = br.readLine()) != null) {
String l = line.trim();
if (l.isEmpty() || l.startsWith("#")) continue;
// Detect top-level or map-key serial of the form SERIAL:
String serialKey = extractSerialMapKey(line);
if (serialKey != null) {
pendingSerial = normalizeSerial(serialKey);
currentIndent = leadingSpaces(line);
continue;
}
// If inside a serial block, parse a name/title field at greater indent
if (pendingSerial != null) {
int indent = leadingSpaces(line);
if (indent <= currentIndent) {
// Out of this block
pendingSerial = null;
continue;
}
String title = extractTitleFromLine(l);
if (title != null) {
out.put(pendingSerial, title);
pendingSerial = null;
}
}
}
} catch (Exception ignored) {}
}
private static boolean loadJsonIfPresent(File file, Map<String, String> out) {
if (file == null || !file.exists()) return false;
try (FileInputStream fis = new FileInputStream(file)) {
InputStreamReader isr = new InputStreamReader(fis, StandardCharsets.UTF_8);
StringBuilder sb = new StringBuilder(1 << 20);
char[] buf = new char[4096];
int n;
while ((n = isr.read(buf)) != -1) sb.append(buf, 0, n);
JSONObject obj = new JSONObject(new JSONTokener(sb.toString()));
java.util.Iterator<String> keys = obj.keys();
while (keys.hasNext()) {
String k = keys.next();
String v = obj.optString(k, null);
if (v != null && !v.isEmpty()) out.put(k, v);
}
return true;
} catch (Exception ignored) {}
return false;
}
private static void writeJsonSafe(File file, Map<String, String> map) {
if (file == null) return;
try {
if (file.getParentFile() != null && !file.getParentFile().exists()) file.getParentFile().mkdirs();
JSONObject obj = new JSONObject(map);
byte[] bytes = obj.toString().getBytes(StandardCharsets.UTF_8);
try (FileOutputStream fos = new FileOutputStream(file, false)) {
fos.write(bytes);
fos.flush();
}
} catch (Exception ignored) {}
}
private static String getCachedTitle(Context ctx, String uri) {
try {
SharedPreferences prefs = ctx.getSharedPreferences("title_cache", Context.MODE_PRIVATE);
return prefs.getString(uri, null);
} catch (Throwable ignored) {}
return null;
}
private static void putCachedTitle(Context ctx, String uri, String title) {
try {
SharedPreferences prefs = ctx.getSharedPreferences("title_cache", Context.MODE_PRIVATE);
prefs.edit().putString(uri, title).apply();
} catch (Throwable ignored) {}
}
private static String extractTitleFromLine(String l) {
// Common YAML keys
int idx = indexOfKey(l, "name:");
if (idx < 0) idx = indexOfKey(l, "title:");
if (idx < 0) return null;
String v = l.substring(idx).trim();
// Strip key
int colon = v.indexOf(':');
if (colon >= 0) v = v.substring(colon + 1).trim();
// Trim quotes if present
if ((v.startsWith("\"") && v.endsWith("\"")) || (v.startsWith("'") && v.endsWith("'"))) {
v = v.substring(1, v.length() - 1);
}
return v.isEmpty() ? null : v;
}
private static int indexOfKey(String l, String key) {
int i = l.toLowerCase(Locale.ROOT).indexOf(key);
return i;
}
private static String extractSerialFromLine(String l) {
String upper = l.toUpperCase(Locale.ROOT);
java.util.regex.Matcher m = java.util.regex.Pattern
.compile("([A-Z]{4,5})[- _]?([0-9]{3})[._]?([0-9]{2})")
.matcher(upper);
if (m.find()) return m.group(1) + "-" + m.group(2) + m.group(3);
return null;
}
private static String extractSerialMapKey(String rawLine) {
// Matches: "SLUS-20312:" or " SLUS_203.12:" (with indentation) at start of key
int colon = rawLine.indexOf(':');
if (colon <= 0) return null;
String key = rawLine.substring(0, colon).trim();
String s = extractSerialFromLine(key);
// Ensure the whole key is a serial, not just contains one
if (s != null) {
String normalizedKey = key.toUpperCase(Locale.ROOT).replace('_','-').replace(".", "");
String normalizedSerial = s.toUpperCase(Locale.ROOT).replace('_','-');
if (normalizedKey.replace("-", "").equals(normalizedSerial.replace("-", ""))) {
return s;
}
}
return null;
}
private static int leadingSpaces(String s) {
int i = 0;
while (i < s.length() && Character.isWhitespace(s.charAt(i))) i++;
return i;
}
private static String normalizeCandidate(String s) {
if (s == null) return null;
String upper = s.toUpperCase(Locale.ROOT).replace('_', '-');
// Try to extract serial
return extractSerialFromLine(upper);
}
private static String normalizeSerial(String serial) {
String s = serial.toUpperCase(Locale.ROOT).replace('_', '-');
s = s.replaceAll("([A-Z]{4,5})-([0-9]{3})\\.([0-9]{2})", "$1-$2$3");
return s;
}
}
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<size android:height="12dp" />
<gradient
android:type="radial"
android:gradientRadius="60dp"
android:centerX="50%"
android:centerY="50%"
android:startColor="#55000000"
android:endColor="#00000000" />
</shape>
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="8dp">
<ImageView
android:id="@+id/image_cover"
android:layout_width="0dp"
android:layout_height="0dp"
android:scaleType="fitCenter"
android:contentDescription="@null"
android:adjustViewBounds="true"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintDimensionRatio="567:878" />
<!-- Soft shadow under the cover for depth -->
<View
android:id="@+id/view_shadow"
android:layout_width="0dp"
android:layout_height="12dp"
android:background="@drawable/bg_cover_shadow"
android:alpha="0"
app:layout_constraintTop_toBottomOf="@id/image_cover"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<TextView
android:id="@+id/text_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:gravity="center"
android:textSize="12sp"
android:textColor="@color/md_theme_onSurface"
android:layout_marginTop="6dp"
android:paddingLeft="4dp"
android:paddingRight="4dp"
android:text="Game"
app:layout_constraintTop_toBottomOf="@id/view_shadow"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>