Files
yattee/Yattee/Services/ToastManager.swift
Arkadiusz Fal 4770bbe3b9 Remove dead code found by Periphery on all platforms
Delete declarations that Periphery reports unused on every platform that
compiles them (iOS, macOS, tvOS), including nine views used only by their
own previews. Platform-gated code still used on another platform, device-
only MPV render paths, migration models, CloudKit record names, App
Intents metadata and unused parameters are kept.
2026-10-08 23:57:56 +02:00

349 lines
13 KiB
Swift

//
// 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<Void, Never>] = [:]
/// Reference to navigation coordinator for checking player visibility.
private weak var navigationCoordinator: NavigationCoordinator?
/// Task for observing player visibility changes.
private var playerVisibilityObserverTask: Task<Void, Never>?
// 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>) -> 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: - Overlay Hosts
/// Toast overlays inside presented sheets, counted per scope and depth.
/// Only the deepest overlay of a scope shows its toasts, so they appear
/// above the sheet instead of behind it.
private var sheetOverlayDepths: [ToastScope: [Int: Int]] = [:]
func registerOverlay(scope: ToastScope, depth: Int) {
guard depth > 0 else { return }
sheetOverlayDepths[scope, default: [:]][depth, default: 0] += 1
}
func unregisterOverlay(scope: ToastScope, depth: Int) {
guard depth > 0, let count = sheetOverlayDepths[scope]?[depth] else { return }
sheetOverlayDepths[scope]?[depth] = count > 1 ? count - 1 : nil
}
/// Depth of the overlay that shows a scope's toasts: 0 for the window's
/// own overlay, higher inside sheets.
func topOverlayDepth(for scope: ToastScope) -> Int {
sheetOverlayDepths[scope]?.keys.max() ?? 0
}
// 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<ToastScope> = [.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: - 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)
}
// 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)
}
/// Cancel the pending auto-dismiss timer for a toast (e.g. while user is interacting with it).
func pauseAutoDismiss(id: UUID) {
dismissTasks[id]?.cancel()
dismissTasks.removeValue(forKey: id)
}
/// Re-arm the auto-dismiss timer for a toast using its original delay.
func resumeAutoDismiss(id: UUID) {
guard let toast = activeToasts.first(where: { $0.id == id }) else { return }
guard !toast.isPersistent || toast.autoDismissDelay > 0 else { return }
dismissTasks[id]?.cancel()
scheduleAutoDismiss(for: toast)
}
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
}
}