diff --git a/.gitignore b/.gitignore index 2ce0f6d..ae0d099 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ app/.cxx/ /PCSX2_ARM64 build/reports/problems/problems-report.html crash.txt +/AetherSX2-main +crash-NEW.txt diff --git a/app/src/main/assets/resources/no-cover.png b/app/src/main/assets/resources/no-cover.png new file mode 100644 index 0000000..2d6c75c Binary files /dev/null and b/app/src/main/assets/resources/no-cover.png differ diff --git a/app/src/main/java/com/izzy2lost/psx2/CoversAdapter.java b/app/src/main/java/com/izzy2lost/psx2/CoversAdapter.java index 8eced0e..b3950e5 100644 --- a/app/src/main/java/com/izzy2lost/psx2/CoversAdapter.java +++ b/app/src/main/java/com/izzy2lost/psx2/CoversAdapter.java @@ -29,31 +29,57 @@ public class CoversAdapter extends RecyclerView.Adapter { 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 { .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 { @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 { diff --git a/app/src/main/java/com/izzy2lost/psx2/GamesCoverDialogFragment.java b/app/src/main/java/com/izzy2lost/psx2/GamesCoverDialogFragment.java index 270c330..f6f5c18 100644 --- a/app/src/main/java/com/izzy2lost/psx2/GamesCoverDialogFragment.java +++ b/app/src/main/java/com/izzy2lost/psx2/GamesCoverDialogFragment.java @@ -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(); diff --git a/app/src/main/java/com/izzy2lost/psx2/NativeApp.java b/app/src/main/java/com/izzy2lost/psx2/NativeApp.java index 2ab15e1..3381b7c 100644 --- a/app/src/main/java/com/izzy2lost/psx2/NativeApp.java +++ b/app/src/main/java/com/izzy2lost/psx2/NativeApp.java @@ -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(); diff --git a/app/src/main/java/com/izzy2lost/psx2/TitleResolver.java b/app/src/main/java/com/izzy2lost/psx2/TitleResolver.java new file mode 100644 index 0000000..442dc5d --- /dev/null +++ b/app/src/main/java/com/izzy2lost/psx2/TitleResolver.java @@ -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 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 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 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 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 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; + } +} diff --git a/app/src/main/res/drawable/bg_cover_shadow.xml b/app/src/main/res/drawable/bg_cover_shadow.xml new file mode 100644 index 0000000..8f40753 --- /dev/null +++ b/app/src/main/res/drawable/bg_cover_shadow.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/layout/item_coverflow.xml b/app/src/main/res/layout/item_coverflow.xml new file mode 100644 index 0000000..d757ff8 --- /dev/null +++ b/app/src/main/res/layout/item_coverflow.xml @@ -0,0 +1,48 @@ + + + + + + + + + + +