From f0b35de92aeaa15a95fee70d426a2db6fc0516aa Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 14 Aug 2026 23:39:41 +0200 Subject: [PATCH] feat(screenshots): the library shelf carries real cover art on every platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Apple scenes passed artLoader nil, so the store's library frames shipped empty frosted cards next to Android's populated shelf. LibraryArtSource (a two-method protocol the production LibraryArtLoader already satisfies) lets the harness answer art from a canned source, and the four posters are drawn procedurally at capture time — CoreGraphics on Apple, Canvas on Android, same designs, same seeds — replacing Android's gradient-plus-monogram tiles. Nothing is bundled; release builds ship none of it. --- .../unom/punktfunk/screenshots/ShotScenes.kt | 250 +++++++++++++++-- .../Home/LibraryCoverflowView.swift | 2 +- .../PunktfunkClient/Home/LibraryView.swift | 4 +- .../PunktfunkClient/Home/LibraryWidgets.swift | 2 +- .../Screenshots/ScreenshotScenes.swift | 23 +- .../Screenshots/ShotPosterArt.swift | 264 ++++++++++++++++++ .../Connection/LibraryClient.swift | 12 +- 7 files changed, 525 insertions(+), 32 deletions(-) create mode 100644 clients/apple/Sources/PunktfunkClient/Screenshots/ShotPosterArt.swift diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt index 40179d8a..dacd6090 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt @@ -3,9 +3,12 @@ package io.unom.punktfunk.screenshots import android.content.Context import android.content.res.Configuration import android.graphics.Bitmap +import android.graphics.BlendMode import android.graphics.Canvas import android.graphics.LinearGradient import android.graphics.Paint +import android.graphics.Path +import android.graphics.RadialGradient import android.graphics.Shader import android.graphics.Typeface import android.graphics.drawable.BitmapDrawable @@ -708,39 +711,250 @@ private fun shotGames() = listOf( private fun shotLibraryLoader(context: Context): ImageLoader { val engine = FakeImageLoaderEngine.Builder() - .intercept("shot://art/aurora", cover(context, 0xFF6656F2, 0xFF141040, "A")) - .intercept("shot://art/starfall", cover(context, 0xFFE86FA8, 0xFF3A1030, "S")) - .intercept("shot://art/neon", cover(context, 0xFF35D0C5, 0xFF0A2A33, "N")) - .intercept("shot://art/ember", cover(context, 0xFFEF8F4B, 0xFF3A1608, "E")) + .intercept("shot://art/aurora", poster(context, "AURORA DRIFT", ::drawAurora)) + .intercept("shot://art/starfall", poster(context, "STARFALL VALE", ::drawStarfall)) + .intercept("shot://art/neon", poster(context, "NEON CIRCUIT", ::drawNeon)) + .intercept("shot://art/ember", poster(context, "EMBER PEAKS", ::drawEmber)) .default(ColorDrawable(0xFF221E44.toInt())) .build() return ImageLoader.Builder(context).components { add(engine) }.build() } -/** A generated 2:3 poster: vertical brand-adjacent gradient + a big monogram. */ -private fun cover(context: Context, top: Long, bottom: Long, mark: String): Drawable { - val w = 600 - val h = 900 - val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) +// The four shelf posters, drawn procedurally at capture time — the same designs the Apple +// harness draws with CoreGraphics (`ShotPosterArt.swift`), so both listings show the same shelf. +// All geometry below is in a 600×900, y-UP space (matching the CG source); `posterY()` flips it. + +private const val POSTER_W = 600 +private const val POSTER_H = 900 + +private fun posterY(v: Float) = POSTER_H - v + +/** Deterministic LCG (same constants and seeds as the Swift twin) so every capture is identical. */ +private class ShotRand(var state: ULong) { + fun next(): Float { + state = state * 6364136223846793005UL + 1442695040888963407UL + return (state shr 33).toFloat() / (1L shl 31).toFloat() + } + fun range(lo: Float, hi: Float) = lo + next() * (hi - lo) +} + +private fun poster(context: Context, title: String, draw: (Canvas) -> Unit): Drawable { + val bmp = Bitmap.createBitmap(POSTER_W, POSTER_H, Bitmap.Config.ARGB_8888) val canvas = Canvas(bmp) + draw(canvas) + posterTitle(canvas, title) + return BitmapDrawable(context.resources, bmp) +} + +/** Vertical gradient over the full canvas; stops bottom-to-top as (location, color). */ +private fun sky(canvas: Canvas, stops: List>) { canvas.drawRect( - 0f, 0f, w.toFloat(), h.toFloat(), + 0f, 0f, POSTER_W.toFloat(), POSTER_H.toFloat(), Paint(Paint.ANTI_ALIAS_FLAG).apply { shader = LinearGradient( - 0f, 0f, 0f, h.toFloat(), top.toInt(), bottom.toInt(), Shader.TileMode.CLAMP, + 0f, POSTER_H.toFloat(), 0f, 0f, + stops.map { it.second }.toIntArray(), + stops.map { it.first }.toFloatArray(), + Shader.TileMode.CLAMP, ) }, ) - canvas.drawText( - mark, w / 2f, h / 2f + 110f, +} + +private fun glowDot(canvas: Canvas, x: Float, y: Float, radius: Float, color: Int) { + canvas.drawCircle( + x, posterY(y), radius, Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = 0xD9FFFFFF.toInt() - textSize = 320f - typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) - textAlign = Paint.Align.CENTER + shader = RadialGradient( + x, posterY(y), radius, color, color and 0x00FFFFFF, Shader.TileMode.CLAMP, + ) }, ) - return BitmapDrawable(context.resources, bmp) +} + +private fun shotAlpha(color: Int, a: Float) = (color and 0x00FFFFFF) or ((a * 255).toInt() shl 24) + +/** Three strokes, wide-and-faint to thin-and-bright, in screen blend — the cheap neon glow. */ +private fun glowStroke(canvas: Canvas, path: Path, width: Float, color: Int) { + for ((mult, a) in listOf(2.6f to 0.12f, 1.3f to 0.28f, 0.55f to 0.85f)) { + canvas.drawPath( + path, + Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeCap = Paint.Cap.ROUND + strokeJoin = Paint.Join.ROUND + strokeWidth = width * mult + this.color = shotAlpha(color, a) + blendMode = BlendMode.SCREEN + }, + ) + } +} + +private fun posterTitle(canvas: Canvas, title: String) { + sky(canvas, listOf(0f to shotAlpha(0x000000, 0.55f), 0.22f to shotAlpha(0x000000, 0f))) + canvas.drawText( + title, POSTER_W / 2f, posterY(72f), + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = shotAlpha(0xFFFFFF, 0.94f) + textSize = 46f + letterSpacing = 5f / 46f + typeface = Typeface.create("sans-serif-condensed", Typeface.BOLD) + textAlign = Paint.Align.CENTER + setShadowLayer(8f, 0f, 2f, shotAlpha(0x000000, 0.6f)) + }, + ) +} + +private fun drawAurora(canvas: Canvas) { + sky(canvas, listOf(0f to 0xFF221E5C.toInt(), 0.45f to 0xFF141040.toInt(), 1f to 0xFF0B0830.toInt())) + val rng = ShotRand(11UL) + repeat(48) { + val x = rng.range(0f, 600f) + val y = rng.range(300f, 890f) + val r = rng.range(1.4f, 3.2f) + glowDot(canvas, x, y, r, shotAlpha(0xFFFFFF, rng.range(0.25f, 0.8f))) + } + data class Ribbon( + val base: Float, val amp: Float, val freq: Float, + val phase: Float, val w: Float, val c: Int, + ) + for (r in listOf( + Ribbon(700f, 55f, 1.15f, 0.4f, 30f, 0xFF6656F2.toInt()), + Ribbon(615f, 70f, 1.4f, 2.2f, 24f, 0xFF8F7BFF.toInt()), + Ribbon(530f, 45f, 0.95f, 4.1f, 18f, 0xFF35D0C5.toInt()), + )) { + val path = Path() + for (i in 0..60) { + val t = i / 60f + val x = t * 600f + val y = r.base + r.amp * kotlin.math.sin(t * Math.PI.toFloat() * r.freq + r.phase) + 40f * t + if (i == 0) path.moveTo(x, posterY(y)) else path.lineTo(x, posterY(y)) + } + glowStroke(canvas, path, r.w, r.c) + } + // A low ridge grounds the scene — without it the poster's bottom half is bare sky. + for ((fill, baseline, rough) in listOf( + Triple(0xFF191345.toInt(), 212f, 30f), + Triple(0xFF0E0A2E.toInt(), 148f, 38f), + )) { + val path = Path() + path.moveTo(0f, posterY(0f)) + path.lineTo(0f, posterY(baseline + rng.range(-rough, rough))) + for (i in 1..9) { + val x = i / 9f * 600f + path.lineTo(x, posterY(baseline + rng.range(-rough, rough))) + } + path.lineTo(600f, posterY(0f)) + path.close() + canvas.drawPath(path, Paint(Paint.ANTI_ALIAS_FLAG).apply { color = fill }) + } +} + +private fun drawStarfall(canvas: Canvas) { + sky( + canvas, + listOf( + 0f to 0xFF2A0C24.toInt(), 0.35f to 0xFF7A2B58.toInt(), + 0.8f to 0xFFE86FA8.toInt(), 1f to 0xFFF7A8C8.toInt(), + ), + ) + val rng = ShotRand(23UL) + repeat(6) { + val hx = rng.range(60f, 560f) + val hy = rng.range(420f, 840f) + val len = rng.range(90f, 170f) + val dx = kotlin.math.cos(2.15f) + val dy = kotlin.math.sin(2.15f) + val path = Path() + path.moveTo(hx, posterY(hy)) + path.lineTo(hx + dx * len, posterY(hy + dy * len)) + glowStroke(canvas, path, 4f, 0xFFFFE3EF.toInt()) + glowDot(canvas, hx, hy, 11f, shotAlpha(0xFFFFFF, 0.9f)) + } + for ((fill, baseline, rough) in listOf( + Triple(0xFF3A1430.toInt(), 300f, 26f), + Triple(0xFF1D0818.toInt(), 216f, 34f), + )) { + val path = Path() + path.moveTo(0f, posterY(0f)) + path.lineTo(0f, posterY(baseline)) + for (i in 1..8) { + val x = i / 8f * 600f + path.lineTo(x, posterY(baseline + rng.range(-rough, rough))) + } + path.lineTo(600f, posterY(0f)) + path.close() + canvas.drawPath(path, Paint(Paint.ANTI_ALIAS_FLAG).apply { color = fill }) + } +} + +private fun drawNeon(canvas: Canvas) { + sky(canvas, listOf(0f to 0xFF0A2A33.toInt(), 1f to 0xFF04161C.toInt())) + val rng = ShotRand(7UL) + val ring = Path().apply { + addOval(300f - 105f, posterY(560f) - 105f, 300f + 105f, posterY(560f) + 105f, Path.Direction.CW) + } + glowStroke(canvas, ring, 10f, 0xFF35D0C5.toInt()) + val gateX = listOf(-105f, 105f, 0f, 0f) + val gateY = listOf(0f, 0f, -105f, 105f) + for (i in 0 until 9) { + var px: Float + var py: Float + if (i < 4) { + px = 300f + gateX[i] + py = 560f + gateY[i] + } else { + px = 40f * kotlin.math.round(rng.range(1f, 14f)) + py = 40f * kotlin.math.round(rng.range(1f, 21f)) + } + val path = Path() + path.moveTo(px, posterY(py)) + var horizontal = rng.next() > 0.5f + repeat(rng.range(3f, 6f).toInt()) { + val step = 40f * kotlin.math.round(rng.range(1f, 4f)) * (if (rng.next() > 0.5f) 1f else -1f) + if (horizontal) px = (px + step).coerceIn(20f, 580f) else py = (py + step).coerceIn(20f, 880f) + path.lineTo(px, posterY(py)) + horizontal = !horizontal + } + val color = if (rng.next() > 0.6f) 0xFF7FE8DE.toInt() else 0xFF35D0C5.toInt() + glowStroke(canvas, path, 5f, color) + glowDot(canvas, px, py, 12f, shotAlpha(color, 0.9f)) + } +} + +private fun drawEmber(canvas: Canvas) { + sky( + canvas, + listOf( + 0f to 0xFF200A04.toInt(), 0.3f to 0xFF7A2E12.toInt(), + 0.42f to 0xFFEF8F4B.toInt(), 1f to 0xFF2A0E06.toInt(), + ), + ) + glowDot(canvas, 300f, 385f, 160f, shotAlpha(0xFFC37A, 0.85f)) + val rng = ShotRand(41UL) + for ((fill, baseline, rough) in listOf( + Triple(0xFF5A2410.toInt(), 340f, 42f), + Triple(0xFF401708.toInt(), 255f, 56f), + Triple(0xFF200A04.toInt(), 165f, 48f), + )) { + val path = Path() + path.moveTo(0f, posterY(0f)) + path.lineTo(0f, posterY(baseline + rng.range(-rough, rough))) + for (i in 1..10) { + val x = i / 10f * 600f + path.lineTo(x, posterY(baseline + rng.range(-rough, rough))) + } + path.lineTo(600f, posterY(0f)) + path.close() + canvas.drawPath(path, Paint(Paint.ANTI_ALIAS_FLAG).apply { color = fill }) + } + repeat(20) { + val x = rng.range(30f, 570f) + val y = rng.range(180f, 620f) + val r = rng.range(2.5f, 6f) + glowDot(canvas, x, y, r, shotAlpha(0xFFB067, rng.range(0.35f, 0.9f))) + } } @Composable diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift index aad4d3fa..7b9b362b 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift @@ -24,7 +24,7 @@ struct LibraryCoverflowView: View { @AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet" private var ink: GamepadInk { .stored(paletteID) } let games: [GameEntry] - let artLoader: LibraryArtLoader? + let artLoader: (any LibraryArtSource)? var onLaunch: ((String) -> Void)? /// Button B (back) — dismisses the library screen. No touch equivalent needed here (the toolbar /// Close button already covers that); this is what makes gamepad-only exit possible. diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift index 7c0c1746..69336acc 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift @@ -74,7 +74,7 @@ struct LibraryView: View { @State private var errorText: String? /// Cover-art loader (the same paired identity + host pinning as the list fetch, reused across /// every poster in the grid). Built alongside `games` in `load()`; dropped on disappear. - @State private var artLoader: LibraryArtLoader? + @State private var artLoader: (any LibraryArtSource)? #if os(iOS) || os(macOS) /// The plain grid's hardware-keyboard cursor (a game id), and the grid width the column count /// is derived from. nil until the first arrow press, so a touch user never sees a selection @@ -409,7 +409,7 @@ private struct LibraryBackCatcher: View { /// (portrait → header → hero) and finally a text placeholder. private struct GameCard: View { let game: GameEntry - let artLoader: LibraryArtLoader? + let artLoader: (any LibraryArtSource)? /// The hardware-keyboard cursor is on this tile — drawn as an accent ring, since the plain /// grid has no other way to say "Return launches THIS one". var selected = false diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift index 7dc9f271..475b240b 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift @@ -70,7 +70,7 @@ private extension Image { struct PosterImage: View { let candidates: [URL] let title: String - let loader: LibraryArtLoader? + let loader: (any LibraryArtSource)? /// The entry's brand-mark token (`GameEntry.iconToken`), when it has one. A launcher tile ships /// no cover art by design, so for those the mark IS the poster — see `placeholder`. var icon: String? diff --git a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift index 080e2427..9ac4ab4e 100644 --- a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift +++ b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift @@ -207,15 +207,20 @@ enum ShotMock { /// A believable shelf for the library coverflow. Decoded rather than constructed: /// `GameEntry`'s memberwise init is internal to PunktfunkKit, and Codable is its public - /// construction surface. No art URLs — the posters render their deterministic fallback - /// (title tiles, the Steam entry its brand mark), which is also what keeps the shot offline. + /// construction surface. The `shot://art/…` posters are answered by [`ShotPosterArt.source`] + /// (drawn at capture time), so the shot stays offline; the Steam launcher entry stays artless + /// by design and renders its brand mark. static let games: [GameEntry] = { let json = """ [ - {"id": "custom:aurora", "store": "custom", "title": "Aurora Drift", "art": {}}, - {"id": "steam:starfall", "store": "steam", "title": "Starfall Vale", "art": {}}, - {"id": "heroic:neon", "store": "heroic", "title": "Neon Circuit", "art": {}}, - {"id": "gog:ember", "store": "gog", "title": "Ember Peaks", "art": {}}, + {"id": "custom:aurora", "store": "custom", "title": "Aurora Drift", + "art": {"portrait": "shot://art/aurora"}}, + {"id": "steam:starfall", "store": "steam", "title": "Starfall Vale", + "art": {"portrait": "shot://art/starfall"}}, + {"id": "heroic:neon", "store": "heroic", "title": "Neon Circuit", + "art": {"portrait": "shot://art/neon"}}, + {"id": "gog:ember", "store": "gog", "title": "Ember Peaks", + "art": {"portrait": "shot://art/ember"}}, {"id": "steam:launcher", "store": "steam", "title": "Steam", "art": {}, "role": "launcher", "icon": "steam"} ] @@ -263,12 +268,12 @@ private struct ShotHome: View { // MARK: - Library /// The library coverflow with the mock shelf — the store listing's PICK & PLAY frame. The real -/// `LibraryCoverflowView`, no network: artless entries settle to their deterministic fallback -/// posters, and the entrance's 700 ms backstop has long fired by the time the driver captures. +/// `LibraryCoverflowView`, no network: `ShotPosterArt` answers the mock entries' art immediately, +/// so the cards swing in already carrying posters (the entrance waits on art settling). private struct ShotLibrary: View { var body: some View { LibraryCoverflowView( - games: ShotMock.games, artLoader: nil, + games: ShotMock.games, artLoader: ShotPosterArt.source, onLaunch: { _ in }, onDismiss: {}, controllerActive: false) } } diff --git a/clients/apple/Sources/PunktfunkClient/Screenshots/ShotPosterArt.swift b/clients/apple/Sources/PunktfunkClient/Screenshots/ShotPosterArt.swift new file mode 100644 index 00000000..63b20442 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Screenshots/ShotPosterArt.swift @@ -0,0 +1,264 @@ +// Procedural cover art for the screenshot shelf. The store's library frames used to render the +// deterministic text-placeholder posters (`artLoader: nil`), which read as an empty library next +// to the Android listing's populated one. These four posters are drawn with CoreGraphics at +// capture time — no bundled assets, nothing in a release build, and the same designs the Android +// harness draws in Canvas, so the two listings show the same shelf. + +#if DEBUG +import CoreText +import Foundation +import ImageIO +import PunktfunkKit +import UniformTypeIdentifiers + +/// A canned `LibraryArtSource`: poster bytes by URL, no network. What the screenshot shelf hands +/// the real coverflow in place of the paired-host loader. +struct ShotArtSource: LibraryArtSource { + let fixtures: [String: Data] + + func data(for url: URL) async throws -> Data { + guard let data = fixtures[url.absoluteString] else { + throw CocoaError(.fileNoSuchFile) + } + return data + } + + func close() async {} +} + +enum ShotPosterArt { + /// Art for `ShotMock.games` — keyed by the `shot://art/…` URLs those entries carry. + static let source = ShotArtSource(fixtures: [ + "shot://art/aurora": poster("AURORA DRIFT", draw: drawAurora), + "shot://art/starfall": poster("STARFALL VALE", draw: drawStarfall), + "shot://art/neon": poster("NEON CIRCUIT", draw: drawNeon), + "shot://art/ember": poster("EMBER PEAKS", draw: drawEmber), + ]) + + private static let W = 600 + private static let H = 900 + + // MARK: - Canvas plumbing + + private static func poster(_ title: String, draw: (CGContext) -> Void) -> Data { + let space = CGColorSpace(name: CGColorSpace.sRGB)! + let ctx = CGContext( + data: nil, width: W, height: H, bitsPerComponent: 8, bytesPerRow: 0, + space: space, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)! + draw(ctx) + drawTitle(ctx, title) + let image = ctx.makeImage()! + let out = NSMutableData() + let dest = CGImageDestinationCreateWithData( + out, UTType.png.identifier as CFString, 1, nil)! + CGImageDestinationAddImage(dest, image, nil) + CGImageDestinationFinalize(dest) + return out as Data + } + + private static func rgb(_ hex: UInt32, _ alpha: CGFloat = 1) -> CGColor { + CGColor( + srgbRed: CGFloat((hex >> 16) & 0xff) / 255, + green: CGFloat((hex >> 8) & 0xff) / 255, + blue: CGFloat(hex & 0xff) / 255, alpha: alpha) + } + + /// Vertical gradient over the full canvas; `stops` bottom-to-top as (location, color). + private static func sky(_ ctx: CGContext, _ stops: [(CGFloat, CGColor)]) { + let gradient = CGGradient( + colorsSpace: CGColorSpace(name: CGColorSpace.sRGB)!, + colors: stops.map(\.1) as CFArray, + locations: stops.map(\.0))! + ctx.drawLinearGradient( + gradient, start: .zero, end: CGPoint(x: 0, y: CGFloat(H)), options: []) + } + + private static func glowDot( + _ ctx: CGContext, at center: CGPoint, radius: CGFloat, color: CGColor + ) { + let clear = color.copy(alpha: 0)! + let gradient = CGGradient( + colorsSpace: CGColorSpace(name: CGColorSpace.sRGB)!, + colors: [color, clear] as CFArray, locations: [0, 1])! + ctx.drawRadialGradient( + gradient, startCenter: center, startRadius: 0, + endCenter: center, endRadius: radius, options: []) + } + + /// Stroke `path` three times, wide-and-faint to thin-and-bright, in screen blend — the cheap + /// neon-glow trick every one of these posters leans on. + private static func glowStroke( + _ ctx: CGContext, _ path: CGPath, width: CGFloat, color: CGColor + ) { + ctx.saveGState() + ctx.setBlendMode(.screen) + ctx.setLineCap(.round) + ctx.setLineJoin(.round) + for (mult, alpha) in [(2.6, 0.12), (1.3, 0.28), (0.55, 0.85)] { + ctx.addPath(path) + ctx.setLineWidth(width * mult) + ctx.setStrokeColor(color.copy(alpha: alpha)!) + ctx.strokePath() + } + ctx.restoreGState() + } + + private static func drawTitle(_ ctx: CGContext, _ title: String) { + // A soft floor behind the caption keeps it legible over any art. + sky(ctx, [(0, rgb(0x000000, 0.55)), (0.22, rgb(0x000000, 0))]) + let font = CTFontCreateWithName("HelveticaNeue-CondensedBold" as CFString, 46, nil) + let text = NSAttributedString(string: title, attributes: [ + .font: font, .kern: 5, .foregroundColor: rgb(0xFFFFFF, 0.94), + ] as [NSAttributedString.Key: Any]) + let line = CTLineCreateWithAttributedString(text) + let bounds = CTLineGetBoundsWithOptions(line, []) + ctx.saveGState() + ctx.setShadow(offset: CGSize(width: 0, height: -2), blur: 8, color: rgb(0x000000, 0.6)) + ctx.textPosition = CGPoint(x: (CGFloat(W) - bounds.width) / 2, y: 72) + CTLineDraw(line, ctx) + ctx.restoreGState() + } + + /// Deterministic LCG so every capture draws the identical poster. + private struct Rand { + var state: UInt64 + mutating func next() -> CGFloat { + state = state &* 6364136223846793005 &+ 1442695040888963407 + return CGFloat(state >> 33) / CGFloat(UInt64(1) << 31) + } + mutating func in_(_ lo: CGFloat, _ hi: CGFloat) -> CGFloat { lo + next() * (hi - lo) } + } + + // MARK: - The four posters + + private static func drawAurora(_ ctx: CGContext) { + sky(ctx, [(0, rgb(0x221E5C)), (0.45, rgb(0x141040)), (1, rgb(0x0B0830))]) + var rng = Rand(state: 11) + for _ in 0..<48 { + let p = CGPoint(x: rng.in_(0, 600), y: rng.in_(300, 890)) + glowDot(ctx, at: p, radius: rng.in_(1.4, 3.2), color: rgb(0xFFFFFF, rng.in_(0.25, 0.8))) + } + let ribbons: [(base: CGFloat, amp: CGFloat, freq: CGFloat, phase: CGFloat, w: CGFloat, c: UInt32)] = [ + (700, 55, 1.15, 0.4, 30, 0x6656F2), + (615, 70, 1.4, 2.2, 24, 0x8F7BFF), + (530, 45, 0.95, 4.1, 18, 0x35D0C5), + ] + for r in ribbons { + let path = CGMutablePath() + for i in 0...60 { + let t = CGFloat(i) / 60 + let p = CGPoint( + x: t * 600, + y: r.base + r.amp * sin(t * .pi * r.freq + r.phase) + 40 * t) + if i == 0 { path.move(to: p) } else { path.addLine(to: p) } + } + glowStroke(ctx, path, width: r.w, color: rgb(r.c)) + } + // A low ridge grounds the scene — without it the poster's bottom half is bare sky. + for (fill, baseline, rough) in [ + (rgb(0x191345), CGFloat(212), CGFloat(30)), + (rgb(0x0E0A2E), CGFloat(148), CGFloat(38)), + ] { + let path = CGMutablePath() + path.move(to: CGPoint(x: 0, y: 0)) + path.addLine(to: CGPoint(x: 0, y: baseline + rng.in_(-rough, rough))) + for i in 1...9 { + let x = CGFloat(i) / 9 * 600 + path.addLine(to: CGPoint(x: x, y: baseline + rng.in_(-rough, rough))) + } + path.addLine(to: CGPoint(x: 600, y: 0)) + path.closeSubpath() + ctx.setFillColor(fill) + ctx.addPath(path) + ctx.fillPath() + } + } + + private static func drawStarfall(_ ctx: CGContext) { + sky(ctx, [(0, rgb(0x2A0C24)), (0.35, rgb(0x7A2B58)), (0.8, rgb(0xE86FA8)), (1, rgb(0xF7A8C8))]) + var rng = Rand(state: 23) + for _ in 0..<6 { + let head = CGPoint(x: rng.in_(60, 560), y: rng.in_(420, 840)) + let len = rng.in_(90, 170) + let dir = CGVector(dx: cos(2.15), dy: sin(2.15)) // ~123° — up-left tails + let path = CGMutablePath() + path.move(to: head) + path.addLine(to: CGPoint(x: head.x + dir.dx * len, y: head.y + dir.dy * len)) + glowStroke(ctx, path, width: 4, color: rgb(0xFFE3EF)) + glowDot(ctx, at: head, radius: 11, color: rgb(0xFFFFFF, 0.9)) + } + for (fill, baseline, rough) in [ + (rgb(0x3A1430), CGFloat(300), CGFloat(26)), + (rgb(0x1D0818), CGFloat(216), CGFloat(34)), + ] { + let path = CGMutablePath() + path.move(to: CGPoint(x: 0, y: 0)) + path.addLine(to: CGPoint(x: 0, y: baseline)) + for i in 1...8 { + let x = CGFloat(i) / 8 * 600 + path.addLine(to: CGPoint(x: x, y: baseline + rng.in_(-rough, rough))) + } + path.addLine(to: CGPoint(x: 600, y: 0)) + path.closeSubpath() + ctx.setFillColor(fill) + ctx.addPath(path) + ctx.fillPath() + } + } + + private static func drawNeon(_ ctx: CGContext) { + sky(ctx, [(0, rgb(0x0A2A33)), (1, rgb(0x04161C))]) + var rng = Rand(state: 7) + let ring = CGPath( + ellipseIn: CGRect(x: 300 - 105, y: 560 - 105, width: 210, height: 210), transform: nil) + glowStroke(ctx, ring, width: 10, color: rgb(0x35D0C5)) + for i in 0..<9 { + // Right-angle traces on a 40 px grid, some feeding out of the ring's four gates. + var p = i < 4 + ? CGPoint(x: 300 + [-105, 105, 0, 0][i], y: 560 + [0, 0, -105, 105][i]) + : CGPoint(x: 40 * (rng.in_(1, 14)).rounded(), y: 40 * (rng.in_(1, 21)).rounded()) + let path = CGMutablePath() + path.move(to: p) + var horizontal = rng.next() > 0.5 + for _ in 0.. 0.5 ? 1 : -1) + p = horizontal ? CGPoint(x: min(max(p.x + step, 20), 580), y: p.y) + : CGPoint(x: p.x, y: min(max(p.y + step, 20), 880)) + path.addLine(to: p) + horizontal.toggle() + } + let color = rng.next() > 0.6 ? rgb(0x7FE8DE) : rgb(0x35D0C5) + glowStroke(ctx, path, width: 5, color: color) + glowDot(ctx, at: p, radius: 12, color: color.copy(alpha: 0.9)!) + } + } + + private static func drawEmber(_ ctx: CGContext) { + sky(ctx, [(0, rgb(0x200A04)), (0.3, rgb(0x7A2E12)), (0.42, rgb(0xEF8F4B)), (1, rgb(0x2A0E06))]) + glowDot(ctx, at: CGPoint(x: 300, y: 385), radius: 160, color: rgb(0xFFC37A, 0.85)) + var rng = Rand(state: 41) + for (fill, baseline, rough) in [ + (rgb(0x5A2410), CGFloat(340), CGFloat(42)), + (rgb(0x401708), CGFloat(255), CGFloat(56)), + (rgb(0x200A04), CGFloat(165), CGFloat(48)), + ] { + let path = CGMutablePath() + path.move(to: CGPoint(x: 0, y: 0)) + path.addLine(to: CGPoint(x: 0, y: baseline + rng.in_(-rough, rough))) + for i in 1...10 { + let x = CGFloat(i) / 10 * 600 + path.addLine(to: CGPoint(x: x, y: baseline + rng.in_(-rough, rough))) + } + path.addLine(to: CGPoint(x: 600, y: 0)) + path.closeSubpath() + ctx.setFillColor(fill) + ctx.addPath(path) + ctx.fillPath() + } + for _ in 0..<20 { + let p = CGPoint(x: rng.in_(30, 570), y: rng.in_(180, 620)) + glowDot(ctx, at: p, radius: rng.in_(2.5, 6), color: rgb(0xFFB067, rng.in_(0.35, 0.9))) + } + } +} +#endif diff --git a/clients/apple/Sources/PunktfunkKit/Connection/LibraryClient.swift b/clients/apple/Sources/PunktfunkKit/Connection/LibraryClient.swift index ba80795b..84aa57df 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/LibraryClient.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/LibraryClient.swift @@ -228,6 +228,16 @@ extension Artwork { } } +/// Anything that answers poster bytes for a cover-art URL. The production implementation is +/// [`LibraryArtLoader`]; the screenshot harness substitutes a canned source so store frames carry +/// artwork without a host on the network. +public protocol LibraryArtSource: Sendable { + func data(for url: URL) async throws -> Data + /// Release pooled connections when the owning screen goes away. Sources without connections + /// have nothing to do. + func close() async +} + /// Loads cover art for the library UI, routing each URL to the transport that suits its origin. /// /// A `GameEntry`'s art candidates mix two very different things: the host's own art proxy @@ -242,7 +252,7 @@ extension Artwork { /// TLS handshake per tile. /// /// Built once per library screen and reused across a whole grid's worth of posters. -public final class LibraryArtLoader: @unchecked Sendable { +public final class LibraryArtLoader: LibraryArtSource, @unchecked Sendable { private let address: String private let port: UInt16 private let identity: SecIdentity -- 2.54.0