Files
iceglass/IceGlass/Managers/MenuManager.swift
T
rzen 541aa3d52c Show full playoff bracket, mark series results, harden API decoding
Playoffs:
- List every round played so far (Round 1 → current) instead of only the
  current round, on both macOS menu and iPhone
- Strike through the eliminated team's tricode in a finished series and drop
  the now-redundant "(Final … wins)" tag on completed earlier rounds
- Refetch the bracket when a finished game implies more completed games than
  the cached bracket records, so the series score and round no longer get
  stuck on stale data after cold launch or the NHL bracket endpoint's lag

API robustness:
- Tolerate optional gameCenterLink/startTimeUTC on TBD playoff matchups so the
  scoreboard decode no longer aborts
- Reject API state regressions via a monotonic FUT→…→OFF progression rank so a
  brief glitch can't downgrade a finished game back to "-:-"
2026-05-30 08:21:28 -04:00

405 lines
14 KiB
Swift

//
// MenuManager.swift
// IceGlass
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import AppKit
class MenuManager: @unchecked Sendable {
private let logger = IceGlassLogger(
subsystem: Bundle.main.bundleIdentifier ?? "dev.rzen.indie.IceGlass",
category: "MenuManager"
)
static let shared = MenuManager()
private lazy var settings = AppSettings.shared
private lazy var mainService = MainService.shared
private lazy var statusItemManager = StatusItemManager.shared
private var menuUpdateTimer: Timer?
private init() {
DispatchQueue.main.async { [weak self] in
guard let self = self else {
AppTerminator.terminate()
return
}
self.logger.info("Initializing")
self.menuUpdateTimer = Timer.startTimer(
timer: self.menuUpdateTimer,
interval: PollingInterval.bootstrap.rawValue
) { [weak self] _ in
guard let self = self else { return }
self.updateMenu()
}
}
}
@MainActor private func isSafe() -> Bool {
if let currentEvent = NSApp.currentEvent,
currentEvent.type == .leftMouseDown || currentEvent.type == .rightMouseDown {
return false
}
return true
}
func scoreboardChanged() {
Task { @MainActor [weak self] in
guard let self = self else { return }
let interval = self.mainService.anyGameLive
? PollingInterval.everyMinute.rawValue
: PollingInterval.idle.rawValue
self.logger.debug("Starting menu update timer \(interval)")
self.menuUpdateTimer = Timer.startTimer(
timer: self.menuUpdateTimer,
interval: interval
) { [weak self] _ in
guard let self = self else { return }
self.updateMenu()
}
}
updateMenuSafely()
}
func updateMenuSafely() {
Task { @MainActor in
if self.isSafe() {
self.updateMenu()
}
}
}
private func updateMenu() {
logger.info("Setting up menu")
let menu = NSMenu()
// Refresh Now
menu.addItem(
NSMenuItem(
title: "Refresh Now",
action: #selector(refreshStats),
keyEquivalent: "r"
).withTarget(self)
)
menu.addItem(NSMenuItem.separator())
let monoFont = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular)
let boldFont = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize, weight: .bold)
let roundGroups = mainService.roundSeriesGroups
let latestRound = roundGroups.last?.round
for group in roundGroups where !group.items.isEmpty {
let headerText = "ROUND \(group.round)"
let headerItem = NSMenuItem(title: headerText, action: nil, keyEquivalent: "")
headerItem.attributedTitle = NSAttributedString(
string: headerText,
attributes: [.font: boldFont]
)
menu.addItem(headerItem)
for item in group.items {
menu.addItem(createRoundSeriesItem(for: item, showStatus: group.round == latestRound, font: monoFont))
}
menu.addItem(NSMenuItem.separator())
}
let gameDays = mainService.gamesByDate
if gameDays.isEmpty {
menu.addItem(NSMenuItem(title: "No Games Available", action: nil, keyEquivalent: ""))
} else {
for (index, gameDay) in gameDays.enumerated() {
// Date header with game count
let dateLabel = Date.fullDateLabel(for: gameDay.date)
let gameCount = gameDay.games.count
let headerText = "\(dateLabel) (\(gameCount))"
let headerItem = NSMenuItem(title: headerText, action: nil, keyEquivalent: "")
headerItem.attributedTitle = NSAttributedString(
string: headerText,
attributes: [.font: boldFont]
)
menu.addItem(headerItem)
if gameDay.games.isEmpty {
let noGames = NSMenuItem(title: " No games scheduled", action: nil, keyEquivalent: "")
noGames.attributedTitle = NSAttributedString(
string: " No games scheduled",
attributes: [
.font: monoFont,
.foregroundColor: NSColor.secondaryLabelColor
]
)
menu.addItem(noGames)
} else {
for game in gameDay.games {
let items = createGameMenuItems(for: game, font: monoFont)
for item in items {
menu.addItem(item)
}
}
}
if index < gameDays.count - 1 {
menu.addItem(NSMenuItem.separator())
}
}
}
menu.addItem(NSMenuItem.separator())
// Display Options submenu
let displayMenuItem = NSMenuItem()
displayMenuItem.title = "Display Options"
let displaySubmenu = NSMenu()
// Date range options
for option in AppSettings.DisplayOption.allCases {
let item = NSMenuItem(
title: option.title,
action: #selector(changeDisplayOption(_:)),
keyEquivalent: ""
)
item.target = self
item.representedObject = option.rawValue
item.state = option == settings.displayOption ? .on : .off
displaySubmenu.addItem(item)
}
displaySubmenu.addItem(NSMenuItem.separator())
// Status bar options
for option in AppSettings.StatusBarOption.allCases {
let item = NSMenuItem(
title: option.title,
action: #selector(changeStatusBarOption(_:)),
keyEquivalent: ""
)
item.target = self
item.representedObject = option.rawValue
item.state = option == settings.statusBarOption ? .on : .off
displaySubmenu.addItem(item)
}
displayMenuItem.submenu = displaySubmenu
menu.addItem(displayMenuItem)
// About IceGlass
menu.addItem(
NSMenuItem(
title: "About IceGlass",
action: #selector(showAbout),
keyEquivalent: ""
).withTarget(self)
)
// Launch at Login
let launchItem = NSMenuItem(
title: "Launch at Login",
action: #selector(toggleLaunchAtLogin(_:)),
keyEquivalent: ""
)
launchItem.target = self
launchItem.state = settings.launchAtLogin ? .on : .off
menu.addItem(launchItem)
#if DEBUG
let devMenuItem = NSMenuItem()
devMenuItem.title = "Developer"
let devSubmenu = NSMenu()
devSubmenu.addItem(
NSMenuItem(title: "Force Refresh", action: #selector(refreshStats), keyEquivalent: "").withTarget(self)
)
devSubmenu.addItem(
NSMenuItem(title: "Test Game Start Notification", action: #selector(triggerTestGameStart), keyEquivalent: "").withTarget(self)
)
devSubmenu.addItem(
NSMenuItem(title: "Test Goal Scored Notification", action: #selector(triggerTestGoalScored), keyEquivalent: "").withTarget(self)
)
devSubmenu.addItem(
NSMenuItem(title: "Test Game Ended Notification", action: #selector(triggerTestGameEnded), keyEquivalent: "").withTarget(self)
)
devSubmenu.addItem(
NSMenuItem(title: "Thumbnail Size Test", action: #selector(triggerThumbnailSizeTest), keyEquivalent: "").withTarget(self)
)
devMenuItem.submenu = devSubmenu
menu.addItem(devMenuItem)
#endif
menu.addItem(NSMenuItem.separator())
menu.addItem(NSMenuItem(title: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"))
statusItemManager.statusItem?.menu = menu
}
// MARK: - Menu Actions
@objc private func refreshStats() {
Task { @MainActor in
await mainService.fetchAll()
}
}
@objc private func handleGameMenuClick(_ sender: NSMenuItem) {
if NSEvent.modifierFlags.contains(.option) {
openGameStream(sender)
} else {
openGame(sender)
}
}
@objc private func openGame(_ sender: NSMenuItem) {
guard let game = sender.representedObject as? Scoreboard.Game,
let urlString = game.gameCenterUrl,
let url = URL(string: urlString) else { return }
NSWorkspace.shared.open(url)
}
@objc private func openGameStream(_ sender: NSMenuItem) {
guard let game = sender.representedObject as? Scoreboard.Game,
let url = URL(string: game.videocastUrl) else { return }
NSWorkspace.shared.open(url)
}
@objc private func openSeriesPage(_ sender: NSMenuItem) {
guard let urlString = sender.representedObject as? String,
let url = URL(string: urlString) else { return }
NSWorkspace.shared.open(url)
}
@objc private func changeDisplayOption(_ sender: NSMenuItem) {
guard let rawValue = sender.representedObject as? String,
let option = AppSettings.DisplayOption(rawValue: rawValue) else { return }
settings.displayOption = option
mainService.updateUI()
}
@objc private func changeStatusBarOption(_ sender: NSMenuItem) {
guard let rawValue = sender.representedObject as? String,
let option = AppSettings.StatusBarOption(rawValue: rawValue) else { return }
settings.statusBarOption = option
mainService.updateUI()
}
@objc private func toggleLaunchAtLogin(_ sender: NSMenuItem) {
settings.launchAtLogin.toggle()
settings.updateLoginItem(enabled: settings.launchAtLogin)
updateMenu()
}
#if DEBUG
@objc private func triggerTestGameStart() {
let notificationManager = NotificationManager.shared
if let game = mainService.gamesByDate.flatMap(\.games).first {
notificationManager.notifyGameStarted(game, bypassDedup: true)
}
}
@objc private func triggerTestGoalScored() {
let notificationManager = NotificationManager.shared
if let game = mainService.gamesByDate.flatMap(\.games).first {
let sampleScorer = GoalScorer(name: "J. Eriksson Ek", sweaterNumber: 14, strength: "PPG")
notificationManager.notifyGoalScored(
game,
scoringTeam: game.homeTeam,
scorer: sampleScorer,
bypassDedup: true
)
}
}
@objc private func triggerTestGameEnded() {
let notificationManager = NotificationManager.shared
if let game = mainService.gamesByDate.flatMap(\.games).first {
notificationManager.notifyGameEnded(game, bypassDedup: true)
}
}
@objc private func triggerThumbnailSizeTest() {
NotificationManager.shared.sendThumbnailSizeTest()
}
#endif
@objc private func showAbout() {
Task { @MainActor in
showAboutWindow()
}
}
// MARK: - Menu Item Creation
private func createGameMenuItems(for game: Scoreboard.Game, font: NSFont) -> [NSMenuItem] {
let prefix = game.gameType == 2
? String(format: "#%4d ", game.seasonGameNumber)
: " "
let title = "\(prefix)\(game.menuTitle)"
let item = NSMenuItem(title: title, action: #selector(handleGameMenuClick(_:)), keyEquivalent: "")
item.target = self
item.representedObject = game
item.attributedTitle = NSAttributedString(string: title, attributes: [.font: font])
let altItem = NSMenuItem(title: title, action: #selector(openGameStream(_:)), keyEquivalent: "")
altItem.target = self
altItem.representedObject = game
altItem.attributedTitle = NSAttributedString(string: title, attributes: [.font: font])
altItem.isAlternate = true
altItem.keyEquivalentModifierMask = .option
return [item, altItem]
}
private func createRoundSeriesItem(for roundItem: MainService.RoundSeriesItem, showStatus: Bool, font: NSFont) -> NSMenuItem {
let series = roundItem.series
let top = series.topSeedTeam?.abbrev ?? "TBD"
let bottom = series.bottomSeedTeam?.abbrev ?? "TBD"
let matchup = "\(bottom) @ \(top)"
let score = "\(series.bottomSeedWins)-\(series.topSeedWins)"
// Completed earlier rounds show just the matchup and score the struck-out
// loser already conveys the result, so the "(Final wins)" tag is dropped.
let title: String
if showStatus {
let statusTag: String
let trailing: String
if let winner = series.winner {
statusTag = "(Final \(winner) wins)"
trailing = ""
} else if let n = series.nextGameNumber {
statusTag = "(Game \(n))"
trailing = roundItem.nextGame?.nextGameLabel ?? ""
} else {
statusTag = ""
trailing = ""
}
let tagColumn = statusTag.padding(toLength: 20, withPad: " ", startingAt: 0)
title = " \(matchup) \(score) \(tagColumn)\(trailing)"
} else {
title = " \(matchup) \(score)"
}
let item = NSMenuItem(title: title, action: #selector(openSeriesPage(_:)), keyEquivalent: "")
item.target = self
item.representedObject = series.fullSeriesUrl
let attributed = NSMutableAttributedString(string: title, attributes: [.font: font])
// Strike through the eliminated team's tricode, searching only within the
// matchup region so the same abbrev inside the "(Final wins)" tag stays plain.
if let loser = series.loser {
let matchupRange = NSRange(location: 2, length: (matchup as NSString).length)
let loserRange = (title as NSString).range(of: loser, options: [], range: matchupRange)
if loserRange.location != NSNotFound {
attributed.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: loserRange)
}
}
item.attributedTitle = attributed
return item
}
}