Rebake team logos at 72×72 for crisp notification thumbnails
The notification thumbnail's native display size is ~48 physical pixels (measured empirically with a size-ladder test attached via the dev menu). Shipping logos at 512×342 forced macOS to downsample ~10×, which is what was producing the blurry/aliased team logos in banner thumbnails. - Pre-render logos at 72×72 (1.5× over native; stays sharp, gives a little extra detail for retina displays without triggering aliasing) - Trim transparent margins before fitting: NHL brand SVGs pad their viewBox generously, so the actual logo was only ~60% of the bundled image. square_logo.swift now scans the alpha channel, crops to the tight bounding box, then fits aspect-preserved into the 72×72 canvas. - Drop the 32 unused TeamLogo_*.imageset asset-catalog entries (dead code since the team-filter feature was removed); notifications load PNGs from the filesystem bundle subdir - Move TeamLogos/ → Resources/TeamLogos/ and update project.yml source paths; excludes: on the recursive scan prevents duplicate flat copies that were bloating the bundle - Simplify NotificationManager: drop SVG fallback (macOS doesn't accept SVG attachments) and content-hash identifier experiments; back to the minimal working config - Dev menu: add "Thumbnail Size Test" which fires a ladder of 10 test notifications (16…128px) for future sizing verification - Fire a test game-start notification on startup in DEBUG builds so the dev loop doesn't require clicking through the menu after each launch - Scripts/square_logo.swift: alpha-bbox trim + aspect-preserved fit
This commit is contained in:
@@ -220,6 +220,9 @@ class MenuManager: @unchecked Sendable {
|
||||
devSubmenu.addItem(
|
||||
NSMenuItem(title: "Test Goal Scored Notification", action: #selector(triggerTestGoalScored), keyEquivalent: "").withTarget(self)
|
||||
)
|
||||
devSubmenu.addItem(
|
||||
NSMenuItem(title: "Thumbnail Size Test", action: #selector(triggerThumbnailSizeTest), keyEquivalent: "").withTarget(self)
|
||||
)
|
||||
devMenuItem.submenu = devSubmenu
|
||||
menu.addItem(devMenuItem)
|
||||
#endif
|
||||
@@ -304,6 +307,10 @@ class MenuManager: @unchecked Sendable {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func triggerThumbnailSizeTest() {
|
||||
NotificationManager.shared.sendThumbnailSizeTest()
|
||||
}
|
||||
#endif
|
||||
|
||||
@objc private func showAbout() {
|
||||
@@ -365,6 +372,10 @@ class MenuManager: @unchecked Sendable {
|
||||
}
|
||||
|
||||
private static func nextGameLabel(for game: Scoreboard.Game) -> String {
|
||||
let state = game.parsedGameState
|
||||
if state.isLive {
|
||||
return state.shortTag
|
||||
}
|
||||
let dayLabel: String
|
||||
switch game.gameDate {
|
||||
case Date.todayET: dayLabel = "Today"
|
||||
@@ -381,6 +392,7 @@ class MenuManager: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
let time = game.startTimeET.trimmingCharacters(in: .whitespaces)
|
||||
return "\(dayLabel) \(time)"
|
||||
let base = "\(dayLabel) \(time)"
|
||||
return state == .pre ? "\(base) (PRE)" : base
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import UniformTypeIdentifiers
|
||||
import UserNotifications
|
||||
|
||||
@@ -143,24 +145,29 @@ class NotificationManager: @unchecked Sendable {
|
||||
|
||||
// MARK: - Team Logo Attachment
|
||||
|
||||
/// Creates a UNNotificationAttachment from the bundled team logo PNG
|
||||
/// Creates a UNNotificationAttachment from the bundled team logo PNG.
|
||||
/// PNGs live on the filesystem (TeamLogos/ folder) rather than the asset
|
||||
/// catalog because UNNotificationAttachment takes a file URL — asset
|
||||
/// catalog images aren't accessible as standalone files.
|
||||
///
|
||||
/// PNGs are pre-rendered at 48×48 (the measured native thumbnail size)
|
||||
/// so macOS doesn't downsample them during display.
|
||||
private func teamLogoAttachment(for teamAbbrev: String) -> UNNotificationAttachment? {
|
||||
// Look for PNG in the TeamLogos bundle directory
|
||||
guard let logoURL = Bundle.main.url(forResource: teamAbbrev, withExtension: "png", subdirectory: "TeamLogos") else {
|
||||
guard let logoURL = Bundle.main.url(
|
||||
forResource: teamAbbrev, withExtension: "png", subdirectory: "TeamLogos"
|
||||
) else {
|
||||
logger.debug("No logo found for \(teamAbbrev)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// UNNotificationAttachment needs its own copy in a temp directory
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("team-logos", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
|
||||
let tempFile = tempDir.appendingPathComponent("\(teamAbbrev)-\(UUID().uuidString).png")
|
||||
|
||||
do {
|
||||
try FileManager.default.copyItem(at: logoURL, to: tempFile)
|
||||
let attachment = try UNNotificationAttachment(
|
||||
return try UNNotificationAttachment(
|
||||
identifier: "team-logo-\(teamAbbrev)",
|
||||
url: tempFile,
|
||||
options: [
|
||||
@@ -168,7 +175,6 @@ class NotificationManager: @unchecked Sendable {
|
||||
UNNotificationAttachmentOptionsThumbnailHiddenKey: false
|
||||
]
|
||||
)
|
||||
return attachment
|
||||
} catch {
|
||||
logger.error("Failed to create logo attachment for \(teamAbbrev): \(error.localizedDescription)")
|
||||
return nil
|
||||
@@ -180,4 +186,103 @@ class NotificationManager: @unchecked Sendable {
|
||||
gameStartsSent.removeAll()
|
||||
scoreChangesSent.removeAll()
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
// MARK: - Thumbnail Size Test
|
||||
|
||||
/// Fires one notification per size in the ladder with a distinctive test
|
||||
/// image generated at that exact pixel size. The crispest thumbnail
|
||||
/// reveals the notification system's native display size.
|
||||
func sendThumbnailSizeTest() {
|
||||
let sizes = [16, 24, 32, 40, 48, 56, 64, 80, 96, 128]
|
||||
for size in sizes {
|
||||
guard let attachment = testAttachment(size: size) else {
|
||||
logger.warning("Failed to create test attachment for size \(size)")
|
||||
continue
|
||||
}
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "Thumb Test"
|
||||
content.body = "\(size)px"
|
||||
content.sound = nil
|
||||
content.interruptionLevel = .active
|
||||
content.attachments = [attachment]
|
||||
|
||||
let request = UNNotificationRequest(
|
||||
identifier: "thumb-test-\(size)-\(UUID().uuidString)",
|
||||
content: content,
|
||||
trigger: UNTimeIntervalNotificationTrigger(timeInterval: 0.1, repeats: false)
|
||||
)
|
||||
UNUserNotificationCenter.current().add(request) { [weak self] error in
|
||||
if let error = error {
|
||||
self?.logger.error("Thumb test \(size)px failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a UNNotificationAttachment with a generated NxN PNG.
|
||||
/// Pattern: solid white background, 1-source-pixel black border, a
|
||||
/// small centered 4x4 black pixel grid (so we can see pixel-level
|
||||
/// behaviour). Unique filename + attachment identifier so macOS
|
||||
/// treats each size as a distinct attachment (no cache reuse).
|
||||
private func testAttachment(size: Int) -> UNNotificationAttachment? {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("thumb-test", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let tempFile = tempDir.appendingPathComponent("thumb-\(size)-\(UUID().uuidString).png")
|
||||
|
||||
guard generateTestPNG(size: size, at: tempFile) else { return nil }
|
||||
|
||||
do {
|
||||
return try UNNotificationAttachment(
|
||||
identifier: "thumb-test-attachment-\(size)-\(UUID().uuidString)",
|
||||
url: tempFile,
|
||||
options: [
|
||||
UNNotificationAttachmentOptionsTypeHintKey: UTType.png.identifier,
|
||||
UNNotificationAttachmentOptionsThumbnailHiddenKey: false
|
||||
]
|
||||
)
|
||||
} catch {
|
||||
logger.error("Failed to create thumb test attachment (\(size)): \(error.localizedDescription)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func generateTestPNG(size: Int, at url: URL) -> Bool {
|
||||
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
|
||||
guard let ctx = CGContext(
|
||||
data: nil, width: size, height: size,
|
||||
bitsPerComponent: 8, bytesPerRow: 0, space: colorSpace,
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
) else { return false }
|
||||
|
||||
// White fill
|
||||
ctx.setFillColor(red: 1, green: 1, blue: 1, alpha: 1)
|
||||
ctx.fill(CGRect(x: 0, y: 0, width: size, height: size))
|
||||
|
||||
// 1-pixel black border (no antialiasing for pixel accuracy)
|
||||
ctx.setShouldAntialias(false)
|
||||
ctx.setFillColor(red: 0, green: 0, blue: 0, alpha: 1)
|
||||
// Top row
|
||||
ctx.fill(CGRect(x: 0, y: size - 1, width: size, height: 1))
|
||||
// Bottom row
|
||||
ctx.fill(CGRect(x: 0, y: 0, width: size, height: 1))
|
||||
// Left column
|
||||
ctx.fill(CGRect(x: 0, y: 0, width: 1, height: size))
|
||||
// Right column
|
||||
ctx.fill(CGRect(x: size - 1, y: 0, width: 1, height: size))
|
||||
|
||||
// Centered 4x4 black square at exact source pixel positions
|
||||
let cx = size / 2
|
||||
let cy = size / 2
|
||||
ctx.fill(CGRect(x: cx - 2, y: cy - 2, width: 4, height: 4))
|
||||
|
||||
guard let image = ctx.makeImage(),
|
||||
let dest = CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil)
|
||||
else { return false }
|
||||
CGImageDestinationAddImage(dest, image, nil)
|
||||
return CGImageDestinationFinalize(dest)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user