// // ToastManager.swift // Yattee // // Manages toast notification lifecycle and display queue. // import Foundation import SwiftUI /// Manages toast notifications across the app. @MainActor @Observable final class ToastManager { // MARK: - State /// Active toasts, sorted by priority (highest first) private(set) var activeToasts: [Toast] = [] /// Auto-dismiss tasks keyed by toast ID private var dismissTasks: [UUID: Task] = [:] /// Reference to navigation coordinator for checking player visibility. private weak var navigationCoordinator: NavigationCoordinator? /// Task for observing player visibility changes. private var playerVisibilityObserverTask: Task? // MARK: - Configuration /// Set the navigation coordinator reference. func setNavigationCoordinator(_ coordinator: NavigationCoordinator) { self.navigationCoordinator = coordinator startPlayerVisibilityObserver() } // MARK: - Scope Resolution /// Determines whether the player is currently visible (expanded sheet on iOS, window on macOS). private var isPlayerVisible: Bool { #if os(macOS) return ExpandedPlayerWindowManager.shared.isPresented #else return navigationCoordinator?.isPlayerExpanded ?? false #endif } /// Resolves the active scope for a toast based on current UI state. /// If player is visible and the toast supports player scope, use player. /// Otherwise use main scope if supported. private func resolveActiveScope(for scopes: Set) -> ToastScope { if isPlayerVisible && scopes.contains(.player) { return .player } if scopes.contains(.main) { return .main } // Fallback to first available scope return scopes.first ?? .main } /// Re-evaluates and updates the active scope for all toasts based on current player visibility. /// This allows toasts to migrate from .main to .player when the player becomes visible. private func updateToastScopes() { guard !self.activeToasts.isEmpty else { return } LoggingService.shared.info("🔄 Updating toast scopes - \(self.activeToasts.count) active toast(s), player visible: \(self.isPlayerVisible)", category: .general) var updated = false for index in self.activeToasts.indices { let toast = self.activeToasts[index] let newActiveScope = resolveActiveScope(for: toast.scopes) if toast.activeScope != newActiveScope { LoggingService.shared.info("📱 Migrating toast '\(toast.title)' from \(String(describing: toast.activeScope)) to \(String(describing: newActiveScope))", category: .general) self.activeToasts[index].activeScope = newActiveScope updated = true } } // Trigger UI update if any scopes changed if updated { LoggingService.shared.info("✅ Toast scope migration complete - UI will refresh", category: .general) // The @Observable macro will handle change notifications automatically // Just need to ensure the array mutation is detected self.activeToasts = self.activeToasts } } /// Start observing player visibility changes to migrate toasts between scopes. private func startPlayerVisibilityObserver() { playerVisibilityObserverTask?.cancel() playerVisibilityObserverTask = Task { [weak self] in guard let self else { return } var lastPlayerVisible = self.isPlayerVisible while !Task.isCancelled { // Wait for navigationCoordinator.isPlayerExpanded to change // (On macOS, ExpandedPlayerWindowManager.isPresented is updated when window shows, // but we track the NavigationCoordinator state which triggers the window) if let coordinator = self.navigationCoordinator { _ = withObservationTracking { coordinator.isPlayerExpanded } onChange: { } } try? await Task.sleep(for: .milliseconds(100)) guard !Task.isCancelled else { break } let currentPlayerVisible = self.isPlayerVisible if currentPlayerVisible != lastPlayerVisible { LoggingService.shared.info("Player visibility changed: \(lastPlayerVisible) -> \(currentPlayerVisible)", category: .general) lastPlayerVisible = currentPlayerVisible self.updateToastScopes() } } } } // MARK: - Filtered Access /// Returns toasts for a specific scope (filtered by activeScope). func toasts(for scope: ToastScope) -> [Toast] { activeToasts.filter { $0.activeScope == scope } } // MARK: - Public API /// Show a toast notification. /// The toast will be displayed in the most appropriate scope based on current UI state. /// If a toast with the same category exists in any scope, it will be replaced. /// - Parameters: /// - scopes: The scopes where this toast can be displayed (default: main and player) /// - category: The category of the toast (affects replacement and priority) /// - title: The title to display /// - subtitle: Optional subtitle for additional context /// - icon: Optional SF Symbol name /// - iconColor: Optional icon color /// - action: Optional action button /// - autoDismissDelay: Time before auto-dismiss (default: 3s) /// - isPersistent: If true, won't auto-dismiss /// - Returns: The ID of the shown toast @discardableResult func show( scopes: Set = [.main, .player], category: ToastCategory, title: String, subtitle: String? = nil, icon: String? = nil, iconColor: Color? = nil, action: ToastAction? = nil, autoDismissDelay: TimeInterval = 3.0, isPersistent: Bool = false ) -> UUID { // Resolve the active scope based on current UI state let activeScope = resolveActiveScope(for: scopes) let toast = Toast( scopes: scopes, activeScope: activeScope, category: category, title: title, subtitle: subtitle, icon: icon, iconColor: iconColor, action: action, autoDismissDelay: autoDismissDelay, isPersistent: isPersistent ) // Cancel and remove existing toast with same category (in any scope) if let existingToast = activeToasts.first(where: { $0.category == category }) { LoggingService.shared.info("Replacing toast for category: \(String(describing: category))", category: .general) dismissInternal(id: existingToast.id, animated: false) } // Add new toast and sort by priority withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { activeToasts.append(toast) activeToasts.sort { $0.category.priority > $1.category.priority } } let scopesStr = scopes.map { String(describing: $0) }.joined(separator: ", ") let playerVisible = self.isPlayerVisible LoggingService.shared.info("📢 Showing toast: '\(title)' in scope: \(String(describing: activeScope)) (supports: [\(scopesStr)]), persistent: \(isPersistent), player visible: \(playerVisible)", category: .general) // Schedule auto-dismiss // For persistent toasts, autoDismissDelay acts as a safety timeout to prevent stuck toasts if !isPersistent || autoDismissDelay > 0 { scheduleAutoDismiss(for: toast) } return toast.id } /// Update an existing toast's title/subtitle and icon, then schedule auto-dismiss. /// Used for persistent toasts that need to show a final state before dismissing. func update( id: UUID, title: String, subtitle: String? = nil, icon: String? = nil, iconColor: Color? = nil, autoDismissDelay: TimeInterval = 2.0 ) { LoggingService.shared.info("ToastManager.update called for id: \(id), title: \(title)", category: .general) LoggingService.shared.info("Active toasts: \(self.activeToasts.map { $0.id })", category: .general) guard let index = activeToasts.firstIndex(where: { $0.id == id }) else { LoggingService.shared.warning("Cannot update toast \(id) - not found in active toasts", category: .general) return } LoggingService.shared.info("Found toast at index \(index), updating...", category: .general) // Update the toast withAnimation(.easeInOut(duration: 0.2)) { activeToasts[index].title = title activeToasts[index].subtitle = subtitle activeToasts[index].icon = icon activeToasts[index].iconColor = iconColor } LoggingService.shared.info("Updated toast: \(title), scheduling dismiss in \(autoDismissDelay) seconds", category: .general) // Cancel any existing dismiss task and schedule new one dismissTasks[id]?.cancel() let task = Task { [weak self] in try? await Task.sleep(for: .seconds(autoDismissDelay)) guard !Task.isCancelled else { return } LoggingService.shared.info("Auto-dismissing toast after update: \(id)", category: .general) self?.dismissInternal(id: id, animated: true) } dismissTasks[id] = task } // MARK: - Player Scope Convenience Methods /// Show a loading toast (player-only scope, longer auto-dismiss delay). @discardableResult func showPlayerLoading(_ title: String, subtitle: String? = nil) -> UUID { show( scopes: [.player], category: .loading, title: title, subtitle: subtitle, icon: nil, // Will show ProgressView iconColor: nil, autoDismissDelay: 30.0 // Long timeout for loading states ) } /// Show a retry toast (player-only scope). @discardableResult func showPlayerRetry(_ title: String, subtitle: String? = nil) -> UUID { show( scopes: [.player], category: .retry, title: title, subtitle: subtitle, icon: "arrow.clockwise", iconColor: .orange, autoDismissDelay: 5.0 ) } /// Show an error toast (player-only scope). @discardableResult func showPlayerError(_ title: String, subtitle: String? = nil) -> UUID { show( scopes: [.player], category: .error, title: title, subtitle: subtitle, icon: "exclamationmark.triangle.fill", iconColor: .red, autoDismissDelay: 5.0 ) } // MARK: - General Convenience Methods /// Show an error toast (shows in player if visible, otherwise main). @discardableResult func showError(_ title: String, subtitle: String? = nil) -> UUID { show( category: .error, title: title, subtitle: subtitle, icon: "exclamationmark.triangle.fill", iconColor: .red, autoDismissDelay: 5.0 ) } /// Show a success toast (shows in player if visible, otherwise main). @discardableResult func showSuccess(_ title: String, subtitle: String? = nil) -> UUID { show( category: .success, title: title, subtitle: subtitle, icon: "checkmark.circle.fill", iconColor: .green, autoDismissDelay: 2.0 ) } /// Show an info toast (shows in player if visible, otherwise main). @discardableResult func showInfo(_ title: String, subtitle: String? = nil) -> UUID { show( category: .info, title: title, subtitle: subtitle, icon: "info.circle.fill", iconColor: .blue, autoDismissDelay: 3.0 ) } // MARK: - Dismiss Methods /// Dismiss a specific toast. func dismiss(id: UUID, animated: Bool = true) { dismissInternal(id: id, animated: animated) } /// Dismiss all toasts in a category (across all scopes). func dismissCategory(_ category: ToastCategory) { let toastsToDismiss = activeToasts.filter { $0.category == category } for toast in toastsToDismiss { dismissInternal(id: toast.id, animated: true) } } /// Dismiss all toasts in a category for a specific active scope. func dismissCategory(_ category: ToastCategory, scope: ToastScope) { let toastsToDismiss = activeToasts.filter { $0.category == category && $0.activeScope == scope } for toast in toastsToDismiss { dismissInternal(id: toast.id, animated: true) } } /// Dismiss all toasts in a specific active scope. func dismissScope(_ scope: ToastScope) { let toastsToDismiss = activeToasts.filter { $0.activeScope == scope } for toast in toastsToDismiss { dismissInternal(id: toast.id, animated: true) } } /// Dismiss all toasts. func dismissAll() { for task in dismissTasks.values { task.cancel() } dismissTasks.removeAll() withAnimation(.easeInOut(duration: 0.25)) { activeToasts.removeAll() } } // MARK: - Private private func dismissInternal(id: UUID, animated: Bool) { dismissTasks[id]?.cancel() dismissTasks.removeValue(forKey: id) if animated { withAnimation(.easeInOut(duration: 0.25)) { activeToasts.removeAll { $0.id == id } } } else { activeToasts.removeAll { $0.id == id } } LoggingService.shared.info("Dismissed toast: \(id)", category: .general) } private func scheduleAutoDismiss(for toast: Toast) { let task = Task { [weak self] in try? await Task.sleep(for: .seconds(toast.autoDismissDelay)) guard !Task.isCancelled else { return } self?.dismissInternal(id: toast.id, animated: true) } dismissTasks[toast.id] = task } }