diff --git a/src/frameport/artwork/fetch.py b/src/frameport/artwork/fetch.py index 8dc0f8c..bdc860f 100644 --- a/src/frameport/artwork/fetch.py +++ b/src/frameport/artwork/fetch.py @@ -20,7 +20,8 @@ KINDS = { "APP_IMG_LOGO_TRANSPARENT": "logo", "APP_IMG_ICON": "icon", } -EXTRA_KINDS = ("square",) # OculusDB's square cover (Rift games) +# OculusDB's square cover (Rift games); FramePort's own cover/banner (no store art) +EXTRA_KINDS = ("square", "cover", "banner") PICKED = ".picked" # marker: the art in this folder was chosen by the user (Find artwork); automatic fetches skip it @@ -76,21 +77,43 @@ def fetch(package: str, apk: Path | None = None, refresh: bool = False, return out, title -def apk_icon(apk: Path) -> bytes | None: - """Largest PNG launcher icon inside the APK (mipmap/drawable).""" +def apk_icon(apk: Path | str | None) -> bytes | None: + """The app's launcher icon from the APK as PNG bytes: the icon the manifest names (pyaxmlparser), else the + largest ic_launcher bitmap. None for vector/adaptive-only icons (no bitmap to use).""" + if not apk or not Path(apk).is_file(): + return None + import io + + from PIL import Image + + dens = ("xxxhdpi", "xxhdpi", "xhdpi", "hdpi", "mdpi") try: with zipfile.ZipFile(apk) as z: - cands = [i for i in z.infolist() if i.filename.startswith("res/") and "ic_launcher" in i.filename - and i.filename.endswith((".png", ".webp"))] - if not cands: - cands = [i for i in z.infolist() if i.filename.startswith("res/mipmap") and i.filename.endswith(".png")] - if not cands: - return None - best = max(cands, key=lambda i: i.file_size) - data = z.read(best) - return data if data[:4] == b"\x89PNG" else None - except (zipfile.BadZipFile, OSError): + listed = set(z.namelist()) + names = [] + try: + from pyaxmlparser import APK + + named = APK(str(apk)).get_app_icon() + if named: + names.append(named) + except Exception: # noqa: BLE001 - unreadable resources: fall back to the usual file names + pass + names += sorted((n for n in listed if n.startswith("res/") and "ic_launcher" in n + and n.endswith((".png", ".webp")) and "foreground" not in n and "background" not in n), + key=lambda n: next((i for i, d in enumerate(dens) if d in n), len(dens))) + for n in names: + if n not in listed or not n.endswith((".png", ".webp")): + continue + with Image.open(io.BytesIO(z.read(n))) as im: + if im.width < 48: + continue + out = io.BytesIO() + im.convert("RGBA").save(out, "PNG") + return out.getvalue() + except (zipfile.BadZipFile, OSError, ValueError): return None + return None def files(package: str) -> list[Path]: diff --git a/src/frameport/artwork/steam.py b/src/frameport/artwork/steam.py index f94dea3..9985e02 100644 --- a/src/frameport/artwork/steam.py +++ b/src/frameport/artwork/steam.py @@ -126,7 +126,44 @@ def steam_set(package: str, title: str = "", apk: str | Path | None = None) -> d return result -PLACEHOLDER_VERSION = 1 +PLACEHOLDER_VERSION = 2 + + +STORE_KINDS = {"portrait", "square", "landscape", "hero"} + + +def ensure_cover(package: str) -> Path | None: + """A cover for FramePort's own library when a game has no store art (e.g. a 2D Android app): the same name + + APK icon design as the Steam placeholder, saved as artwork//cover.jpg. None when store art exists.""" + from ..core import library + + d = fetch.artwork_dir(package) + if {p.stem for p in fetch.files(package)} & STORE_KINDS: + return None + g = library.game(package) or {} + if not any(d.glob("icon.*")): # the icon also shows in lists (Frame page) + icon = fetch.apk_icon(g.get("apk")) + if icon: + (d / "icon.png").write_bytes(icon) + # icon-only (the card and the game page show the name anyway): a tall cover for cards, a wide banner for the page + title = g.get("title") or package + cover, banner = d / "cover.jpg", d / "banner.jpg" + stamp = d / ".cover" + key = f"{PLACEHOLDER_VERSION}:{title}:{next((p.stat().st_size for p in d.glob('icon.*')), 0)}" + if cover.exists() and banner.exists() and stamp.exists() and stamp.read_text(encoding="utf-8") == key: + return cover + icon = _load_icon(next(iter(sorted(d.glob("icon.*"))), None), g.get("apk")) + background = _background_for(title) + # cover: icon a bit above the middle (the card's title sits at the bottom); banner: icon on the right (the game + # page draws the title on the left) + for path, size, side, centre in ((cover, (600, 900), 300, (0.5, 0.4)), (banner, (1600, 600), 320, (0.75, 0.5))): + im = background(size) + if icon is not None: + ic = icon.resize((side, side)) + im.paste(ic, (round(size[0] * centre[0] - side / 2), round(size[1] * centre[1] - side / 2)), ic) + im.save(path, "JPEG", quality=90, optimize=True) + stamp.write_text(key, encoding="utf-8") + return cover def steam_set_for(package: str) -> dict[str, Path]: @@ -138,38 +175,16 @@ def steam_set_for(package: str) -> dict[str, Path]: def _apk_icon(apk: str | Path | None): - """The APK's launcher icon as a PIL image, or None (adaptive/vector icons have no bitmap to use).""" - if not apk or not Path(apk).is_file(): - return None + """The APK's launcher icon as a PIL image, or None (see fetch.apk_icon).""" import io - import zipfile from PIL import Image - try: - with zipfile.ZipFile(apk) as z: - names = [] - try: - from pyaxmlparser import APK - - icon = APK(str(apk)).get_app_icon() - if icon: - names.append(icon) - except Exception: # noqa: BLE001 - unreadable resources: fall back to the usual file names - pass - dens = ("xxxhdpi", "xxhdpi", "xhdpi", "hdpi", "mdpi") - names += sorted((n for n in z.namelist() if "ic_launcher" in n and n.endswith((".png", ".webp")) - and "foreground" not in n and "background" not in n), - key=lambda n: next((i for i, d in enumerate(dens) if d in n), len(dens))) - for n in names: - if n.endswith((".png", ".webp")) and n in z.namelist(): - im = Image.open(io.BytesIO(z.read(n))) - im.load() - if im.width >= 48: - return im.convert("RGBA") - except Exception: # noqa: BLE001 - not a readable APK + data = fetch.apk_icon(apk) + if not data: return None - return None + with Image.open(io.BytesIO(data)) as im: + return im.convert("RGBA") def _font(size: int): @@ -209,28 +224,40 @@ def _title_block(draw, text: str, box: tuple[int, int, int, int], max_size: int, draw.text((x0 + (x1 - x0 - w) / 2, top + i * line_h), line, font=font, fill=fill) -def _placeholder(out_dir: Path, title: str, icon_file: Path | None, apk) -> dict[str, Path]: - """Name-on-colour art (portrait, landscape, hero, logo, icon), the hue picked from the title.""" +def _background_for(title: str): + """A vertical gradient in a colour picked from the title: background(size) -> PIL image.""" import colorsys - from PIL import Image, ImageDraw + from PIL import Image hue = int(hashlib.sha1(title.encode()).hexdigest()[:4], 16) / 0xFFFF top = tuple(round(c * 255) for c in colorsys.hls_to_rgb(hue, 0.30, 0.45)) bottom = tuple(round(c * 255) for c in colorsys.hls_to_rgb(hue, 0.10, 0.40)) - icon = None - if icon_file: - try: - with Image.open(icon_file) as im: - icon = im.convert("RGBA") - except Exception: # noqa: BLE001 - icon = None - if icon is None: - icon = _apk_icon(apk) def background(size): mask = Image.linear_gradient("L").resize(size) # 0 at the top → 255 at the bottom return Image.composite(Image.new("RGB", size, bottom), Image.new("RGB", size, top), mask) + return background + + +def _load_icon(icon_file: Path | None, apk): + from PIL import Image + + if icon_file: + try: + with Image.open(icon_file) as im: + return im.convert("RGBA") + except Exception: # noqa: BLE001 + pass + return _apk_icon(apk) + + +def _placeholder(out_dir: Path, title: str, icon_file: Path | None, apk) -> dict[str, Path]: + """Name-on-colour art (portrait, landscape, hero, logo, icon), the hue picked from the title.""" + from PIL import Image, ImageDraw + + icon = _load_icon(icon_file, apk) + background = _background_for(title) result: dict[str, Path] = {} for kind, (w, h) in SIZES.items(): diff --git a/src/frameport/artwork/thumbs.py b/src/frameport/artwork/thumbs.py index f901ccf..824856d 100644 --- a/src/frameport/artwork/thumbs.py +++ b/src/frameport/artwork/thumbs.py @@ -12,7 +12,8 @@ from pathlib import Path from ..core.paths import user_data_dir from . import fetch -WIDTHS = {"portrait": 400, "square": 400, "landscape": 1280, "hero": 1280, "icon": 96, "logo": 600} +WIDTHS = {"portrait": 400, "square": 400, "cover": 400, "banner": 1280, "landscape": 1280, "hero": 1280, "icon": 96, + "logo": 600} _locks: dict[str, threading.Lock] = {} _guard = threading.Lock() diff --git a/src/frameport/pipeline.py b/src/frameport/pipeline.py index 1cb8103..459a7db 100644 --- a/src/frameport/pipeline.py +++ b/src/frameport/pipeline.py @@ -59,6 +59,9 @@ def add_path(path: Path, reporter: Reporter | None = None, on_added=None, force_ if art: try: artwork.fetch(entry["package"], Path(entry["apk"])) + from .artwork.steam import ensure_cover + + ensure_cover(entry["package"]) # no store art (2D apps): a cover with its name and icon except Exception: # noqa: BLE001 pass if not entry.get("details"): diff --git a/src/frameport/ui/app.py b/src/frameport/ui/app.py index 256e144..af02325 100644 --- a/src/frameport/ui/app.py +++ b/src/frameport/ui/app.py @@ -98,6 +98,7 @@ class FramePortApp: self.go("welcome" if needed() else "library") threading.Thread(target=self._startup, daemon=True).start() threading.Thread(target=self._poll, daemon=True).start() + threading.Thread(target=self._backfill_covers, daemon=True).start() self.updater.start() # ================================================================== shell @@ -1293,6 +1294,22 @@ class FramePortApp: self.refresh_view() return True + def _backfill_covers(self) -> None: + """Games without store art (2D Android apps) get a cover with their name and icon (once; cached).""" + from ..artwork import steam + + made = 0 + for g in library.games(): + if g.get("kind") == "rift": + continue + try: + had = (steam.fetch.artwork_dir(g["package"]) / "cover.jpg").exists() + made += bool(steam.ensure_cover(g["package"])) and not had + except Exception: # noqa: BLE001 - artwork is optional + applog.log.info("cover for %s failed", g.get("package"), exc_info=True) + if made: + self.refresh_view() + def _keep_frame_awake(self) -> None: """Hold a wake lock on the Frame while jobs that use it run or wait (renewed every 30 min; it expires on its own after an hour if FramePort goes away), release it when they're done.""" diff --git a/src/frameport/ui/views/game.py b/src/frameport/ui/views/game.py index ac86781..710e459 100644 --- a/src/frameport/ui/views/game.py +++ b/src/frameport/ui/views/game.py @@ -49,7 +49,7 @@ class GameView: from .library import display_title a = g["analysis"] - art = thumbs.url(pkg, ("hero", "landscape", "portrait", "square"), 1280, wait=False) + art = thumbs.url(pkg, ("hero", "landscape", "portrait", "square", "banner"), 1280, wait=False) platform = C.platform(g)[1] facts = " · ".join(x for x in (platform, a.get("engine"), a.get("xr")) if x and x != "?") recipe = g.get("recipe") or {} diff --git a/src/frameport/ui/views/library.py b/src/frameport/ui/views/library.py index 5418813..34efb62 100644 --- a/src/frameport/ui/views/library.py +++ b/src/frameport/ui/views/library.py @@ -132,6 +132,7 @@ def save_filters(f: dict) -> None: # ------------------------------------------------------------------------------------------ view BATCH = 8 +CARD_ART = ("portrait", "square", "cover", "icon") # store art first; cover = FramePort's own (no store art) def card_shadow(hover: bool = False) -> ft.BoxShadow: """Library cards float on the dark background; hovering lifts them further.""" if hover: @@ -511,14 +512,14 @@ class LibraryView: pkg = g["package"] job = self.app.jobs.busy_with(pkg) return (display_title(g, tw), (g.get("recipe") or {}).get("status"), C.install_state(g, self.app.frame_info), - pkg in pc, bool(job), self.app.quick_action(g)[0], thumbs.url(pkg, ("portrait", "square", "icon")), + pkg in pc, bool(job), self.app.quick_action(g)[0], thumbs.url(pkg, CARD_ART), self.app.frame_state) def card(self, g: dict, pc: set[str], tw: set[str]) -> ft.Control: app = self.app pkg = g["package"] rift = g.get("kind") == "rift" - art = thumbs.url(pkg, ("portrait", "square", "icon")) + art = thumbs.url(pkg, CARD_ART) state = C.install_state(g, app.frame_info) on_pc = pkg in pc status = (g.get("recipe") or {}).get("status", "unknown") diff --git a/tests/test_art.py b/tests/test_art.py index 2998009..a631171 100644 --- a/tests/test_art.py +++ b/tests/test_art.py @@ -310,3 +310,26 @@ def test_picked_art_survives_installs(monkeypatch): assert (d / "portrait.png").read_bytes() == png("blue") and (d / "icon.png").exists() fetch.fetch("com.picked", refresh=True) # "Find automatically": the store art replaces everything assert (d / "portrait.png").read_bytes() == png("red") + + +def test_apps_without_store_art_get_a_cover(tmp_path): + """FramePort's library shows a cover (name + APK icon) for apps no store knows; store art always wins.""" + import zipfile + + from PIL import Image + + from frameport.artwork import fetch, steam, thumbs + from frameport.core import library + + icon = tmp_path / "icon.png" + Image.new("RGBA", (96, 96), (10, 200, 30, 255)).save(icon) + apk = tmp_path / "app.apk" + with zipfile.ZipFile(apk, "w") as z: + z.write(icon, "res/mipmap-xxxhdpi-v4/ic_launcher.png") + library.upsert_game("org.example.flat", title="Flat App", apk=str(apk)) + cover = steam.ensure_cover("org.example.flat") + assert cover and cover.name == "cover.jpg" and (fetch.artwork_dir("org.example.flat") / "icon.png").exists() + assert thumbs.pick("org.example.flat", ("portrait", "square", "cover", "icon")).name == "cover.jpg" + Image.new("RGB", (60, 90)).save(fetch.artwork_dir("org.example.flat") / "portrait.jpg") # store art arrives + assert steam.ensure_cover("org.example.flat") is None + assert thumbs.pick("org.example.flat", ("portrait", "square", "cover", "icon")).name == "portrait.jpg"