AppState.swift

10.2 KB · raw

1import Foundation2import Combine3import AppKit45enum 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?64    65    // Pagination66    @Published var canLoadMore = true67    private var currentPage = 168    private var isFetchingMore = false69    70    // 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 = AutostartManager.shared.isEnabled {78        didSet { AutostartManager.shared.isEnabled = launchAtLogin }79    }80    81    // Filters82    @Published var categories: (general: Bool, anime: Bool, people: Bool) = (true, true, true)83    @Published var purity: (sfw: Bool, sketchy: Bool) = (true, false)84    85    let availableResolutions = ["Any", "1920x1080", "2560x1440", "3840x2160", "5120x2880"]86    let availableRatios = ["Any", "16x9", "16x10", "21x9", "32x9", "4x3"]87    88    private let api = WallhavenAPI()89    private var cancellables = Set<AnyCancellable>()90    91    init() {92        // Load settings93        self.saveToFolderEnabled = UserDefaults.standard.bool(forKey: "saveToFolderEnabled")94        if let path = UserDefaults.standard.string(forKey: "saveFolderPath") {95            self.saveFolderPath = path96        } else {97            let pictures = FileManager.default.urls(for: .picturesDirectory, in: .userDomainMask).first!98            self.saveFolderPath = pictures.appendingPathComponent("Haven").path99        }100        101        // Debounce search102        $searchText103            .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)104            .sink { [weak self] _ in105                Task { [weak self] in106                    await self?.fetchWallpapers()107                }108            }109            .store(in: &cancellables)110            111        // Observe filters112        $categories113            .map { _ in () }114            .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)115            .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }116            .store(in: &cancellables)117            118        $purity119            .map { _ in () }120            .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)121            .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }122            .store(in: &cancellables)123124        $feedMode125            .dropFirst()126            .sink { [weak self] _ in127                Task { [weak self] in await self?.fetchWallpapers(reset: true) }128            }129            .store(in: &cancellables)130131        updateLocalWallpapersMonitoring()132        133        $saveToFolderEnabled134            .dropFirst()135            .sink { [weak self] _ in136                self?.updateLocalWallpapersMonitoring()137            }138            .store(in: &cancellables)139            140        $saveFolderPath141            .dropFirst()142            .sink { [weak self] _ in143                self?.updateLocalWallpapersMonitoring()144            }145            .store(in: &cancellables)146    }147    148    private func updateLocalWallpapersMonitoring() {149        if saveToFolderEnabled {150            LocalWallpapersManager.shared.startMonitoring(folderPath: saveFolderPath)151        } else {152            LocalWallpapersManager.shared.stopMonitoring()153        }154    }155    156    func fetchWallpapers(reset: Bool = true, refreshing: Bool = false) async {157        if reset {158            if !refreshing {159                isLoading = true160                wallpapers = []161            }162            errorMessage = nil163            currentPage = 1164            canLoadMore = true165        } else {166            guard !isFetchingMore && canLoadMore else { return }167            isFetchingMore = true168        }169        170        do {171            let resolutions = selectedResolution == "Any" || selectedResolution.isEmpty ? [] : [selectedResolution]172            let ratios = selectedRatio == "Any" || selectedRatio.isEmpty ? [] : [selectedRatio]173            174            // Construct bitstrings175            let catStr = "\(categories.general ? "1" : "0")\(categories.anime ? "1" : "0")\(categories.people ? "1" : "0")"176            // NSFW is intentionally unsupported; the third purity bit is always 0.177            let purityStr = "\(purity.sfw ? "1" : "0")\(purity.sketchy ? "1" : "0")0"178            179            let (newWallpapers, meta) = try await api.searchWallpapers(180                query: searchText,181                categories: catStr,182                purity: purityStr,183                sorting: feedMode.sorting,184                resolutions: resolutions,185                ratios: ratios,186                limit: 24,187                page: currentPage188            )189            190            if reset {191                self.wallpapers = newWallpapers192            } else {193                self.wallpapers.append(contentsOf: newWallpapers)194            }195            196            if let meta = meta {197                self.canLoadMore = meta.current_page < meta.last_page198                self.currentPage = meta.current_page + 1199            } else {200                self.canLoadMore = !newWallpapers.isEmpty201                if !newWallpapers.isEmpty { self.currentPage += 1 }202            }203            204        } catch {205            if reset {206                self.errorMessage = error.localizedDescription207            }208        }209        210        if reset {211            isLoading = false212        } else {213            isFetchingMore = false214        }215    }216    217    func loadMore() async {218        await fetchWallpapers(reset: false)219    }220    221    func setWallpaper(_ wallpaper: Wallpaper, on screen: NSScreen? = nil) async {222        do {223            let localURL = try await WallpaperManager.shared.download(from: URL(string: wallpaper.path)!)224            225            try await MainActor.run {226                try WallpaperManager.shared.setDesktopImage(from: localURL, screen: screen)227            }228            229            // Add to history230            historyManager.addToHistory(wallpaper)231            232            if saveToFolderEnabled {233                let destFolder = URL(fileURLWithPath: saveFolderPath)234                try WallpaperManager.shared.saveToFolder(from: localURL, to: destFolder)235            }236            237        } catch {238            self.errorMessage = "Failed to set wallpaper: \(error.localizedDescription)"239        }240    }241    242    func setLocalWallpaper(_ wallpaper: LocalWallpaper, on screen: NSScreen? = nil) async {243        do {244            try await MainActor.run {245                try WallpaperManager.shared.setDesktopImage(from: wallpaper.url, screen: screen)246            }247            248            let historyWallpaper = Wallpaper(249                id: wallpaper.id,250                url: wallpaper.url.path,251                path: wallpaper.url.path,252                resolution: wallpaper.resolution,253                purity: "local",254                file_size: Int(wallpaper.fileSize),255                thumbs: Wallpaper.Thumbs(256                    large: wallpaper.url.path,257                    original: wallpaper.url.path,258                    small: wallpaper.url.path259                )260            )261            historyManager.addToHistory(historyWallpaper)262            263        } catch {264            self.errorMessage = "Failed to set wallpaper: \(error.localizedDescription)"265        }266    }267    268    func downloadWallpaper(_ wallpaper: Wallpaper) async {269         do {270            let localURL = try await WallpaperManager.shared.download(from: URL(string: wallpaper.path)!)271            272            // Always save to folder if download requested, or ensure save folder is used273            let destFolder = URL(fileURLWithPath: saveFolderPath)274            try WallpaperManager.shared.saveToFolder(from: localURL, to: destFolder)275            276        } catch {277            self.errorMessage = "Failed to download: \(error.localizedDescription)"278        }279    }280    281    func selectSaveFolder() {282        // Hold the transient popover open; without this the picker steals focus283        // and the popover closes before a folder can be chosen.284        MenuController.shared?.beginModalPresentation()285        NSApp.activate(ignoringOtherApps: true)286287        let panel = NSOpenPanel()288        panel.canChooseFiles = false289        panel.canChooseDirectories = true290        panel.canCreateDirectories = true291        panel.allowsMultipleSelection = false292        panel.directoryURL = URL(fileURLWithPath: saveFolderPath)293        panel.prompt = "Choose"294295        panel.begin { response in296            if response == .OK, let url = panel.url {297                self.saveFolderPath = url.path298            }299            MenuController.shared?.endModalPresentation()300        }301    }302    303    func quit() {304        NSApp.terminate(nil)305    }306}