57358797e1
- Fetch NHL standings and surface league/season game counts in the menu bar
- Prefix regular-season rows with the league-wide game number (from gameId)
- New ROUND section shows each active playoff series (matchup, series score,
next game number + time) derived from /v1/playoff-bracket; rows always open
the NHL series page so completed series remain clickable
- Goal notifications include scorer sweater, abbreviated name, and strength
(PPG/SHG/EN), resolved via /v1/gamecenter/{id}/play-by-play
- Drop the per-team filter submenu and NHLTeam enum
- Regenerate AppIcon with the full 10-size macOS set (alpha preserved) so
notifications render the app icon correctly; rename the iOS marketing PNG
to icon-ios-1024.png
- gitignore .claude/ local tooling settings
387 lines
14 KiB
Swift
387 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 roundItems = mainService.currentRoundSeriesItems
|
|
if !roundItems.isEmpty, let round = mainService.bracket?.currentRound {
|
|
let headerText = "ROUND \(round)"
|
|
let headerItem = NSMenuItem(title: headerText, action: nil, keyEquivalent: "")
|
|
headerItem.attributedTitle = NSAttributedString(
|
|
string: headerText,
|
|
attributes: [.font: boldFont]
|
|
)
|
|
menu.addItem(headerItem)
|
|
|
|
for item in roundItems {
|
|
menu.addItem(createRoundSeriesItem(for: item, 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)
|
|
)
|
|
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 url = URL(string: game.gameCenterUrl) 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
|
|
)
|
|
}
|
|
}
|
|
#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, 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)"
|
|
|
|
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.map { Self.nextGameLabel(for: $0) } ?? ""
|
|
} else {
|
|
statusTag = ""
|
|
trailing = ""
|
|
}
|
|
let tagColumn = statusTag.padding(toLength: 20, withPad: " ", startingAt: 0)
|
|
let title = " \(matchup) \(score) \(tagColumn)\(trailing)"
|
|
|
|
let item = NSMenuItem(title: title, action: #selector(openSeriesPage(_:)), keyEquivalent: "")
|
|
item.target = self
|
|
item.representedObject = series.fullSeriesUrl
|
|
item.attributedTitle = NSAttributedString(string: title, attributes: [.font: font])
|
|
return item
|
|
}
|
|
|
|
private static func nextGameLabel(for game: Scoreboard.Game) -> String {
|
|
let dayLabel: String
|
|
switch game.gameDate {
|
|
case Date.todayET: dayLabel = "Today"
|
|
case Date.tomorrowET: dayLabel = "Tomorrow"
|
|
case Date.yesterdayET: dayLabel = "Yesterday"
|
|
default:
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "yyyy-MM-dd"
|
|
formatter.timeZone = TimeZone(identifier: "America/New_York")
|
|
if let date = formatter.date(from: game.gameDate) {
|
|
dayLabel = date.formatDateET(format: "EEE")
|
|
} else {
|
|
dayLabel = ""
|
|
}
|
|
}
|
|
let time = game.startTimeET.trimmingCharacters(in: .whitespaces)
|
|
return "\(dayLabel) \(time)"
|
|
}
|
|
}
|