AppState.swift

10.2 KB · raw

1import AppKit2import Combine3import Foundation45enum AppTab {6  case explore7  case local8  case favorites9  case history10  case settings11}1213/// Browse mode for the Explore tab. Each maps to a Wallhaven `sorting`14/// value; the free-text search box applies on top of the chosen mode.15enum FeedMode: String, CaseIterable, Identifiable {16  case latest17  case toplist18  case random1920  var id: String { rawValue }2122  var sorting: WallhavenSorting {23    switch self {24    case .latest: return .dateAdded25    case .toplist: return .toplist26    case .random: return .random27    }28  }2930  var displayName: String {31    switch self {32    case .latest: return "Latest"33    case .toplist: return "Toplist"34    case .random: return "Random"35    }36  }3738  var systemImage: String {39    switch self {40    case .latest: return "clock"41    case .toplist: return "diamond"42    case .random: return "arrow.triangle.2.circlepath"43    }44  }45}4647@MainActor48class AppState: ObservableObject {49  // Navigation50  @Published var activeTab: AppTab = .explore5152  // Data Sources53  @Published var wallpapers: [Wallpaper] = []54  @Published var favoritesManager = FavoritesManager.shared55  @Published var historyManager = HistoryManager.shared5657  // Search & Filter58  @Published var feedMode: FeedMode = .latest59  @Published var searchText: String = ""60  @Published var selectedResolution: String = ""61  @Published var selectedRatio: String = ""62  @Published var isLoading: Bool = false63  @Published var errorMessage: String?6465  // Pagination66  @Published var canLoadMore = true67  private var currentPage = 168  private var isFetchingMore = false6970  // Settings71  @Published var saveToFolderEnabled: Bool {72    didSet { UserDefaults.standard.set(saveToFolderEnabled, forKey: "saveToFolderEnabled") }73  }74  @Published var saveFolderPath: String {75    didSet { UserDefaults.standard.set(saveFolderPath, forKey: "saveFolderPath") }76  }77  @Published var launchAtLogin: Bool {78    didSet { autostartManager.isEnabled = launchAtLogin }79  }8081  // Filters82  @Published var categories: (general: Bool, anime: Bool, people: Bool) = (true, true, true)83  @Published var purity: (sfw: Bool, sketchy: Bool) = (true, false)8485  let availableResolutions = ["Any", "1920x1080", "2560x1440", "3840x2160", "5120x2880"]86  let availableRatios = ["Any", "16x9", "16x10", "21x9", "32x9", "4x3"]8788  private let api: WallhavenSearching89  private let wallpaperManager: WallpaperManaging90  private let autostartManager: AutostartManaging91  private let chooseFolder: @MainActor (URL, @escaping (URL?) -> Void) -> Void92  private let terminateApplication: @MainActor () -> Void93  private var cancellables = Set<AnyCancellable>()9495  init(96    api: WallhavenSearching? = nil,97    wallpaperManager: WallpaperManaging? = nil,98    autostartManager: AutostartManaging? = nil,99    chooseFolder: (@MainActor (URL, @escaping (URL?) -> Void) -> Void)? = nil,100    terminateApplication: (@MainActor () -> Void)? = nil,101    observeChanges: Bool = true102  ) {103    let api = api ?? WallhavenAPI()104    let wallpaperManager = wallpaperManager ?? WallpaperManager.shared105    let autostartManager = autostartManager ?? AutostartManager.shared106    self.api = api107    self.wallpaperManager = wallpaperManager108    self.autostartManager = autostartManager109    self.chooseFolder = chooseFolder ?? AppState.presentFolderPicker110    self.terminateApplication = terminateApplication ?? { NSApp.terminate(nil) }111    self.launchAtLogin = autostartManager.isEnabled112    // Load settings113    self.saveToFolderEnabled = UserDefaults.standard.bool(forKey: "saveToFolderEnabled")114    if let path = UserDefaults.standard.string(forKey: "saveFolderPath") {115      self.saveFolderPath = path116    } else {117      let pictures = FileManager.default.urls(for: .picturesDirectory, in: .userDomainMask).first!118      self.saveFolderPath = pictures.appendingPathComponent("Haven").path119    }120121    guard observeChanges else { return }122123    // Debounce search124    $searchText125      .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)126      .sink { [weak self] _ in self?.scheduleSearch() }127      .store(in: &cancellables)128129    // Observe filters130    $categories131      .map { _ in () }132      .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)133      .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }134      .store(in: &cancellables)135136    $purity137      .map { _ in () }138      .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)139      .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }140      .store(in: &cancellables)141142    $feedMode143      .dropFirst()144      .sink { [weak self] _ in145        Task { [weak self] in await self?.fetchWallpapers(reset: true) }146      }147      .store(in: &cancellables)148149    updateLocalWallpapersMonitoring()150151    $saveToFolderEnabled152      .dropFirst()153      .sink { [weak self] _ in154        self?.updateLocalWallpapersMonitoring()155      }156      .store(in: &cancellables)157158    $saveFolderPath159      .dropFirst()160      .sink { [weak self] _ in161        self?.updateLocalWallpapersMonitoring()162      }163      .store(in: &cancellables)164  }165166  private func updateLocalWallpapersMonitoring() {167    if saveToFolderEnabled {168      LocalWallpapersManager.shared.startMonitoring(folderPath: saveFolderPath)169    } else {170      LocalWallpapersManager.shared.stopMonitoring()171    }172  }173174  func scheduleSearch() { Task { [weak self] in await self?.fetchWallpapers() } }175176  func fetchWallpapers(reset: Bool = true, refreshing: Bool = false) async {177    if reset {178      if !refreshing {179        isLoading = true180        wallpapers = []181      }182      errorMessage = nil183      currentPage = 1184      canLoadMore = true185    } else {186      guard !isFetchingMore && canLoadMore else { return }187      isFetchingMore = true188    }189190    do {191      let resolutions =192        selectedResolution == "Any" || selectedResolution.isEmpty ? [] : [selectedResolution]193      let ratios = selectedRatio == "Any" || selectedRatio.isEmpty ? [] : [selectedRatio]194195      // Construct bitstrings196      let catStr =197        "\(categories.general ? "1" : "0")\(categories.anime ? "1" : "0")\(categories.people ? "1" : "0")"198      // NSFW is intentionally unsupported; the third purity bit is always 0.199      let purityStr = "\(purity.sfw ? "1" : "0")\(purity.sketchy ? "1" : "0")0"200201      let (newWallpapers, meta) = try await api.searchWallpapers(202        query: searchText,203        categories: catStr,204        purity: purityStr,205        sorting: feedMode.sorting,206        resolutions: resolutions,207        ratios: ratios,208        limit: 24,209        page: currentPage210      )211212      if reset {213        self.wallpapers = newWallpapers214      } else {215        self.wallpapers.append(contentsOf: newWallpapers)216      }217218      if let meta = meta {219        self.canLoadMore = meta.current_page < meta.last_page220        self.currentPage = meta.current_page + 1221      } else {222        self.canLoadMore = !newWallpapers.isEmpty223        if !newWallpapers.isEmpty { self.currentPage += 1 }224      }225226    } catch {227      if reset {228        self.errorMessage = error.localizedDescription229      }230    }231232    if reset {233      isLoading = false234    } else {235      isFetchingMore = false236    }237  }238239  func loadMore() async {240    await fetchWallpapers(reset: false)241  }242243  func setWallpaper(_ wallpaper: Wallpaper, on screen: NSScreen? = nil) async {244    do {245      let localURL = try await wallpaperManager.download(from: URL(string: wallpaper.path)!)246247      try await MainActor.run {248        try wallpaperManager.setDesktopImage(from: localURL, screen: screen)249      }250251      // Add to history252      historyManager.addToHistory(wallpaper)253254      if saveToFolderEnabled {255        let destFolder = URL(fileURLWithPath: saveFolderPath)256        try wallpaperManager.saveToFolder(from: localURL, to: destFolder)257      }258259    } catch {260      self.errorMessage = "Failed to set wallpaper: \(error.localizedDescription)"261    }262  }263264  func setLocalWallpaper(_ wallpaper: LocalWallpaper, on screen: NSScreen? = nil) async {265    do {266      try await MainActor.run {267        try wallpaperManager.setDesktopImage(from: wallpaper.url, screen: screen)268      }269270      let historyWallpaper = Wallpaper(271        id: wallpaper.id,272        url: wallpaper.url.path,273        path: wallpaper.url.path,274        resolution: wallpaper.resolution,275        purity: "local",276        file_size: Int(wallpaper.fileSize),277        thumbs: Wallpaper.Thumbs(278          large: wallpaper.url.path,279          original: wallpaper.url.path,280          small: wallpaper.url.path281        )282      )283      historyManager.addToHistory(historyWallpaper)284285    } catch {286      self.errorMessage = "Failed to set wallpaper: \(error.localizedDescription)"287    }288  }289290  func downloadWallpaper(_ wallpaper: Wallpaper) async {291    do {292      let localURL = try await wallpaperManager.download(from: URL(string: wallpaper.path)!)293294      // Always save to folder if download requested, or ensure save folder is used295      let destFolder = URL(fileURLWithPath: saveFolderPath)296      try wallpaperManager.saveToFolder(from: localURL, to: destFolder)297298    } catch {299      self.errorMessage = "Failed to download: \(error.localizedDescription)"300    }301  }302303  func selectSaveFolder() {304    // Hold the transient popover open; without this the picker steals focus305    // and the popover closes before a folder can be chosen.306    MenuController.shared?.beginModalPresentation()307    NSApp.activate(ignoringOtherApps: true)308309    chooseFolder(URL(fileURLWithPath: saveFolderPath)) { url in310      if let url { self.saveFolderPath = url.path }311      MenuController.shared?.endModalPresentation()312    }313  }314315  static func makeFolderPanel(at directory: URL) -> NSOpenPanel {316    let panel = NSOpenPanel()317    panel.canChooseFiles = false318    panel.canChooseDirectories = true319    panel.canCreateDirectories = true320    panel.allowsMultipleSelection = false321    panel.directoryURL = directory322    panel.prompt = "Choose"323    return panel324  }325326  static func presentFolderPicker(327    at directory: URL, completion: @escaping (URL?) -> Void328  ) {329    presentFolderPicker(panel: makeFolderPanel(at: directory), completion: completion)330  }331332  static func presentFolderPicker(333    panel: NSOpenPanel, completion: @escaping (URL?) -> Void334  ) {335    panel.begin { response in completion(response == .OK ? panel.url : nil) }336  }337338  func quit() {339    terminateApplication()340  }341}