541aa3d52c
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 "-:-"
405 lines
14 KiB
Swift
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
|
|
}
|
|
|
|
}
|